Auth Redux Removal (#4924)

* all login methods no longer use redux

* removed redux from registration

* redirect user from registration

* removed redux from sso invite

* removed redundant component

* refactored user settings page

* removed redux from logout

* cleaned up unused redux calls

* lint fixes

* removed test

* removed old config interface

* fixed registration bug

* team permission fix

* removed remaining redux references from pages - #4436

* better way to set config
This commit is contained in:
Martavis Parker
2022-04-07 09:08:00 -07:00
committed by GitHub
parent 1cf784999c
commit cfb1474eb8
89 changed files with 1455 additions and 2484 deletions
@@ -1,38 +0,0 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { push } from "react-router-redux";
import { IUser } from "interfaces/user";
import permissionUtils from "utilities/permissions";
import paths from "router/paths";
interface IAccessRoutes {
children: JSX.Element;
}
interface IRootState {
auth: {
user: IUser;
};
}
const { FLEET_403 } = paths;
const AccessRoutes = ({ children }: IAccessRoutes): JSX.Element | null => {
const dispatch = useDispatch();
const user = useSelector((state: IRootState) => state.auth.user);
// user is an empty object here. The API result has not come back
// so render nothing.
if (Object.keys(user).length === 0) {
return null;
}
if (permissionUtils.isNoAccess(user)) {
dispatch(push(FLEET_403));
return null;
}
return <>{children}</>;
};
export default AccessRoutes;
@@ -1 +0,0 @@
export { default } from "./AccessRoutes";
@@ -4,11 +4,13 @@ import { useQuery } from "react-query";
import FileSaver from "file-saver";
import { NotificationContext } from "context/notification";
import configAPI from "services/entities/config";
import { AppContext } from "context/app"; // @ts-ignore
import { stringToClipboard } from "utilities/copy_text";
import { ITeam } from "interfaces/team";
import { IEnrollSecret } from "interfaces/enroll_secret";
import configAPI from "services/entities/config";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton"; // @ts-ignore
import InputField from "components/forms/fields/InputField";
@@ -80,10 +82,10 @@ const PlatformWrapper = ({
}
);
let tlsHostname = config?.server_url || "";
let tlsHostname = config?.server_settings.server_url || "";
try {
const serverUrl = new URL(config?.server_url || "");
const serverUrl = new URL(config?.server_settings.server_url || "");
tlsHostname = serverUrl.hostname;
if (serverUrl.port) {
tlsHostname += `:${serverUrl.port}`;
@@ -225,12 +227,14 @@ const PlatformWrapper = ({
const renderInstallerString = (platform: string) => {
return platform === "advanced"
? `fleetctl package --type=rpm --fleet-url=${config?.server_url}
? `fleetctl package --type=rpm --fleet-url=${config?.server_settings.server_url}
--enroll-secret=${enrollSecret}
--fleet-certificate=PATH_TO_YOUR_CERTIFICATE/fleet.pem`
: `fleetctl package --type=${platform} ${
includeFleetDesktop ? "--fleet-desktop " : ""
}--fleet-url=${config?.server_url} --enroll-secret=${enrollSecret}`;
}--fleet-url=${
config?.server_settings.server_url
} --enroll-secret=${enrollSecret}`;
};
const renderLabel = (platform: string, installerString: string) => {
+53 -55
View File
@@ -1,23 +1,20 @@
import React, { useContext, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import classnames from "classnames";
import { AxiosResponse } from "axios";
import { InjectedRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "react-query";
import classnames from "classnames";
import { authToken } from "utilities/local"; // @ts-ignore
import { useDeepEffect } from "utilities/hooks"; // @ts-ignore
import { fetchCurrentUser } from "redux/nodes/auth/actions"; // @ts-ignore
import { getConfig, getEnrollSecret } from "redux/nodes/app/actions";
import { IConfig } from "interfaces/config";
import { IEnrollSecret } from "interfaces/enroll_secret";
import { ITeamSummary } from "interfaces/team";
import { IUser } from "interfaces/user";
import PATHS from "router/paths";
import TableProvider from "context/table";
import QueryProvider from "context/query";
import PolicyProvider from "context/policy";
import NotificationProvider from "context/notification";
import { AppContext } from "context/app";
import { authToken } from "utilities/local"; // @ts-ignore
import { useDeepEffect } from "utilities/hooks";
import usersAPI from "services/entities/users";
import configAPI from "services/entities/config";
import { ErrorBoundary } from "react-error-boundary"; // @ts-ignore
import Fleet403 from "pages/errors/Fleet403"; // @ts-ignore
@@ -27,62 +24,57 @@ import Spinner from "components/Spinner";
interface IAppProps {
children: JSX.Element;
router: InjectedRouter;
}
interface ISecretResponse {
spec: {
secrets: IEnrollSecret[];
};
}
interface IRootState {
auth: {
user: IUser;
available_teams: ITeamSummary[];
};
}
const App = ({ children }: IAppProps): JSX.Element => {
const dispatch = useDispatch();
const user = useSelector((state: IRootState) => state.auth.user);
const availableTeams = useSelector(
(state: IRootState) => state.auth.available_teams
);
const App = ({ children, router }: IAppProps): JSX.Element => {
const queryClient = new QueryClient();
const {
setAvailableTeams,
setCurrentUser,
setConfig,
setEnrollSecret,
currentUser,
isGlobalObserver,
isOnlyObserver,
isAnyTeamMaintainerOrTeamAdmin,
setAvailableTeams,
setCurrentUser,
setConfig,
setEnrollSecret,
} = useContext(AppContext);
const [isLoading, setIsLoading] = useState<boolean>(false);
useDeepEffect(() => {
const fetchCurrentUser = async () => {
try {
const { user, available_teams } = await usersAPI.me();
setCurrentUser(user);
setAvailableTeams(available_teams);
} catch (error) {
router.push(PATHS.LOGIN);
}
};
const fetchConfig = async () => {
try {
const config = await configAPI.loadAll();
setConfig(config);
} catch (error) {
console.error(error);
return false;
} finally {
setIsLoading(false);
}
};
// on page refresh
if (!user && authToken()) {
// Auth token is not turning to null fast enough so the user is refetched and is making an unneeded API call to enroll_secret
dispatch(fetchCurrentUser()).catch(() => false);
if (!currentUser && authToken()) {
fetchCurrentUser();
}
if (user) {
if (currentUser) {
setIsLoading(true);
setCurrentUser(user);
setAvailableTeams(availableTeams);
dispatch(getConfig())
.then((config: IConfig) => {
setConfig(config);
})
.catch(() => false)
.finally(() => {
setIsLoading(false);
});
fetchConfig();
}
}, [user]);
}, [currentUser]);
useDeepEffect(() => {
const canGetEnrollSecret =
@@ -94,12 +86,18 @@ const App = ({ children }: IAppProps): JSX.Element => {
typeof isAnyTeamMaintainerOrTeamAdmin !== "undefined" &&
!isAnyTeamMaintainerOrTeamAdmin;
const getEnrollSecret = async () => {
try {
const { spec } = await configAPI.loadEnrollSecret();
setEnrollSecret(spec.secrets);
} catch (error) {
console.error(error);
return false;
}
};
if (canGetEnrollSecret) {
dispatch(getEnrollSecret())
.then((response: ISecretResponse) => {
setEnrollSecret(response.spec.secrets);
})
.catch(() => false);
getEnrollSecret();
}
}, [currentUser, isGlobalObserver, isOnlyObserver]);
@@ -110,7 +108,7 @@ const App = ({ children }: IAppProps): JSX.Element => {
console.error(error);
const overlayError = error as AxiosResponse;
if (overlayError.status === 403) {
if (overlayError.status === 403 || overlayError.status === 402) {
return <Fleet403 />;
}
@@ -1,124 +0,0 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import AuthenticatedRoutes from "./index";
import helpers from "../../test/helpers";
describe("AuthenticatedRoutes - component", () => {
const redirectToLoginAction = [
{
payload: { redirectLocation: {} },
type: "SET_REDIRECT_LOCATION",
},
{
payload: { args: ["/login"], method: "push" },
type: "@@router/CALL_HISTORY_METHOD",
},
];
const redirectToPasswordResetAction = [
{
payload: { redirectLocation: {} },
type: "SET_REDIRECT_LOCATION",
},
{
payload: { args: ["/login"], method: "push" },
type: "@@router/CALL_HISTORY_METHOD",
},
];
const renderedText = "This text was rendered";
const storeWithUser = {
auth: {
loading: false,
user: {
id: 1,
email: "hi@thegnar.co",
force_password_reset: false,
},
},
routing: {
locationBeforeTransitions: {},
},
};
const storeWithUserRequiringPwReset = {
auth: {
loading: false,
user: {
id: 1,
email: "hi@thegnar.co",
force_password_reset: true,
},
},
routing: {
locationBeforeTransitions: {},
},
};
const storeLoadingUser = {
auth: {
loading: true,
user: null,
},
routing: {
locationBeforeTransitions: {},
},
};
it("renders if there is a user in state", () => {
const { reduxMockStore } = helpers;
const mockStore = reduxMockStore(storeWithUser);
render(
<Provider store={mockStore}>
<AuthenticatedRoutes>
<div>{renderedText}</div>
</AuthenticatedRoutes>
</Provider>
);
expect(screen.getByText(renderedText)).toBeInTheDocument();
});
it("redirects to reset password is force_password_reset is true", () => {
const { reduxMockStore } = helpers;
const mockStore = reduxMockStore(storeWithUserRequiringPwReset);
render(
<Provider store={mockStore}>
<AuthenticatedRoutes>
<div>{renderedText}</div>
</AuthenticatedRoutes>
</Provider>
);
expect(mockStore.getActions()).toEqual(redirectToPasswordResetAction);
});
// TODO: Cannot test functional components with state
// it("redirects to login without a user", () => {
// const { reduxMockStore } = helpers;
// const mockStore = reduxMockStore(storeWithoutUser);
// const component = mount(
// <Provider store={mockStore}>
// <AuthenticatedRoutes>
// <div>{renderedText}</div>
// </AuthenticatedRoutes>
// </Provider>
// );
// expect(mockStore.getActions()).toContainEqual(redirectToLoginAction);
// expect(component.html()).toBeFalsy();
// });
it("does not redirect to login if the user is loading", () => {
const { reduxMockStore } = helpers;
const mockStore = reduxMockStore(storeLoadingUser);
render(
<Provider store={mockStore}>
<AuthenticatedRoutes>
<div>{renderedText}</div>
</AuthenticatedRoutes>
</Provider>
);
expect(mockStore.getActions()).not.toContainEqual(redirectToLoginAction);
expect(screen.queryByText(renderedText)).not.toBeInTheDocument();
});
});
@@ -1,52 +1,43 @@
import React from "react";
import { push } from "react-router-redux";
import { useDispatch, useSelector } from "react-redux";
import React, { useContext } from "react";
import { InjectedRouter } from "react-router";
import paths from "router/paths";
import { IRedirectLocation } from "interfaces/redirect_location"; // @ts-ignore
import { setRedirectLocation } from "redux/nodes/redirectLocation/actions";
import { IUser } from "interfaces/user";
import { AppContext } from "context/app";
import { RoutingContext } from "context/routing";
import { useDeepEffect } from "utilities/hooks";
import { authToken } from "utilities/local";
interface IAppProps {
children: JSX.Element;
location: any; // no type in react-router v3
router: InjectedRouter;
}
interface IRootState {
auth: {
user: IUser;
};
routing: {
locationBeforeTransitions: IRedirectLocation;
};
}
export const AuthenticatedRoutes = ({ children, location }: IAppProps) => {
const dispatch = useDispatch();
const { user } = useSelector((state: IRootState) => state.auth);
const { locationBeforeTransitions } = useSelector(
(state: IRootState) => state.routing
);
export const AuthenticatedRoutes = ({
children,
location,
router,
}: IAppProps) => {
const { setRedirectLocation } = useContext(RoutingContext);
const { currentUser } = useContext(AppContext);
const redirectToLogin = () => {
const { LOGIN } = paths;
dispatch(setRedirectLocation(locationBeforeTransitions));
return dispatch(push(LOGIN));
setRedirectLocation(window.location.pathname);
return router.push(LOGIN);
};
const redirectToPasswordReset = () => {
const { RESET_PASSWORD } = paths;
return dispatch(push(RESET_PASSWORD));
return router.push(RESET_PASSWORD);
};
const redirectToApiUserOnly = () => {
const { API_ONLY_USER } = paths;
return dispatch(push(API_ONLY_USER));
return router.push(API_ONLY_USER);
};
useDeepEffect(() => {
@@ -56,20 +47,20 @@ export const AuthenticatedRoutes = ({ children, location }: IAppProps) => {
return redirectToLogin();
}
if (user && user.force_password_reset) {
if (currentUser?.force_password_reset && !authToken()) {
return redirectToPasswordReset();
}
if (user && user.api_only) {
if (currentUser?.api_only) {
return redirectToApiUserOnly();
}
}, [user]);
}, [currentUser]);
useDeepEffect(() => {
window.scrollTo(0, 0);
}, [location]);
if (!user) {
if (!currentUser) {
return false;
}
@@ -1,62 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { noop } from "lodash";
import { push } from "react-router-redux";
import paths from "router/paths";
import userInterface from "interfaces/user";
export default (WrappedComponent) => {
class EnsureUnauthenticated extends Component {
static propTypes = {
currentUser: userInterface,
dispatch: PropTypes.func.isRequired,
isLoadingUser: PropTypes.bool,
};
static defaultProps = {
dispatch: noop,
};
componentWillMount() {
const { currentUser, dispatch } = this.props;
const { HOME } = paths;
if (currentUser) {
dispatch(push(HOME));
}
return false;
}
componentWillReceiveProps(nextProps) {
const { currentUser, dispatch } = nextProps;
const { HOME } = paths;
if (currentUser) {
dispatch(push(HOME));
}
return false;
}
render() {
const { currentUser, isLoadingUser } = this.props;
if (isLoadingUser || currentUser) {
return false;
}
return <WrappedComponent {...this.props} />;
}
}
const mapStateToProps = (state) => {
const { loading: isLoadingUser, user: currentUser } = state.auth;
return { currentUser, isLoadingUser };
};
return connect(mapStateToProps)(EnsureUnauthenticated);
};
@@ -1 +0,0 @@
export { default } from "./EnsureUnauthenticated";
@@ -1,85 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { hideBackgroundImage } from "redux/nodes/app/actions";
import { ssoSettings } from "redux/nodes/auth/actions";
import LoginPage, { PreviewLoginPage } from "pages/LoginPage";
export class LoginRoutes extends Component {
static propTypes = {
children: PropTypes.element,
dispatch: PropTypes.func,
isResetPassPage: PropTypes.bool,
isForgotPassPage: PropTypes.bool,
isPreviewLoginPage: PropTypes.bool,
pathname: PropTypes.string,
token: PropTypes.string,
router: PropTypes.any, // eslint-disable-line
};
componentWillMount() {
const { dispatch } = this.props;
dispatch(ssoSettings()).catch(() => false);
dispatch(hideBackgroundImage);
}
componentWillUnmount() {
const { dispatch } = this.props;
dispatch(hideBackgroundImage);
}
render() {
const {
children,
isResetPassPage,
isForgotPassPage,
isPreviewLoginPage,
pathname,
token,
router,
} = this.props;
if (isPreviewLoginPage) {
return <PreviewLoginPage />;
}
return (
<div className="login-routes">
{children || (
<LoginPage
pathname={pathname}
token={token}
isForgotPassPage={isForgotPassPage}
isResetPassPage={isResetPassPage}
router={router}
/>
)}
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const {
location: { pathname, query },
} = ownProps;
const { token } = query;
const isForgotPassPage = pathname.endsWith("/login/forgot");
const isResetPassPage = pathname.endsWith("/login/reset");
const isPreviewLoginPage = pathname.endsWith("/previewlogin");
return {
isForgotPassPage,
isResetPassPage,
isPreviewLoginPage,
pathname,
token,
};
};
export default connect(mapStateToProps)(LoginRoutes);
@@ -1,9 +0,0 @@
.login-routes {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
min-height: 100vh;
background-color: $gradients-dark-gradient-vertical;
}
-1
View File
@@ -1 +0,0 @@
export { default } from "./LoginRoutes";
+5 -7
View File
@@ -1,8 +1,6 @@
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import React, { useContext, useEffect } from "react";
import classnames from "classnames";
// @ts-ignore
import { hideFlash } from "redux/nodes/notifications/actions";
import { NotificationContext } from "context/notification";
const baseClass = "modal";
@@ -19,7 +17,8 @@ const Modal = ({
title,
className,
}: IModalProps): JSX.Element => {
const dispatch = useDispatch();
const { hideFlash } = useContext(NotificationContext);
useEffect(() => {
const closeWithEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
@@ -27,8 +26,7 @@ const Modal = ({
}
};
dispatch(hideFlash);
hideFlash();
document.addEventListener("keydown", closeWithEscapeKey);
return () => {
@@ -1,40 +0,0 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { push } from "react-router-redux";
import { IConfig } from "interfaces/config";
import permissionUtils from "utilities/permissions";
import paths from "router/paths";
interface IPremiumTierRoutes {
children: JSX.Element;
}
interface IRootState {
app: {
config: IConfig;
};
}
const { FLEET_403 } = paths;
const PremiumTierRoutes = ({
children,
}: IPremiumTierRoutes): JSX.Element | null => {
const dispatch = useDispatch();
const config = useSelector((state: IRootState) => state.app.config);
// config is an empty object here. The API result has not come back
// so render nothing.
if (Object.keys(config).length === 0) {
return null;
}
if (!permissionUtils.isPremiumTier(config)) {
dispatch(push(FLEET_403));
return null;
}
return <>{children}</>;
};
export default PremiumTierRoutes;
@@ -1 +0,0 @@
export { default } from "./PremiumTierRoutes";
@@ -16,7 +16,7 @@ import {
useTable,
} from "react-table";
import { isString, kebabCase, noop } from "lodash";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { useDeepEffect } from "utilities/hooks";
import sort from "utilities/sort";
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { IAceEditor } from "react-ace/lib/types";
import { noop, size } from "lodash";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { ILabel, ILabelFormData } from "interfaces/label";
import Button from "components/buttons/Button"; // @ts-ignore
@@ -1,7 +1,7 @@
import { IConfigNested } from "interfaces/config";
import { IConfig } from "interfaces/config";
export interface IAppConfigFormProps {
appConfig: IConfigNested;
appConfig: IConfig;
handleSubmit: any;
}
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
// @ts-ignore
import InputField from "../InputField";
@@ -4,16 +4,13 @@ import classnames from "classnames";
import { IUser } from "interfaces/user";
import { IConfig } from "interfaces/config";
import LinkWithContext from "components/LinkWithContext";
import UserMenu from "components/top_nav/UserMenu";
// @ts-ignore
import OrgLogoIcon from "components/icons/OrgLogoIcon";
import { AppContext } from "context/app";
import navItems, { INavItem } from "./navItems";
import LinkWithContext from "components/LinkWithContext";
import UserMenu from "components/top_nav/UserMenu"; // @ts-ignore
import OrgLogoIcon from "components/icons/OrgLogoIcon";
import navItems, { INavItem } from "./navItems";
import HostsIcon from "../../../../assets/images/icon-main-hosts@2x-16x16@2x.png";
import SoftwareIcon from "../../../../assets/images/icon-software-16x16@2x.png";
import QueriesIcon from "../../../../assets/images/icon-main-queries@2x-16x16@2x.png";
@@ -45,7 +42,7 @@ const SiteTopNav = ({
const renderNavItem = (navItem: INavItem) => {
const { name, iconName, withContext } = navItem;
const orgLogoURL = config.org_logo_url;
const orgLogoURL = config.org_info.org_logo_url;
const active = navItem.location.regex.test(pathname);
const navItemBaseClass = "site-nav-item";
+1 -1
View File
@@ -184,7 +184,7 @@ const reducer = (state: InitialStateType, action: IAction) => {
return {
...state,
config,
...setPermissions(state.currentUser, config),
...setPermissions(state.currentUser, config, state.currentTeam?.id),
};
}
case ACTIONS.SET_ENROLL_SECRET: {
+47
View File
@@ -0,0 +1,47 @@
import React, { createContext, useReducer, ReactNode } from "react";
type Props = {
children: ReactNode;
};
type InitialStateType = {
redirectLocation: string | null;
setRedirectLocation: (pathname: string | null) => void;
};
const initialState = {
redirectLocation: null,
setRedirectLocation: () => null,
};
const actions = {
SET_REDIRECT_LOCATION: "SET_REDIRECT_LOCATION",
};
const reducer = (state: any, action: any) => {
switch (action.type) {
case actions.SET_REDIRECT_LOCATION:
return { ...state, redirectLocation: action.pathname };
default:
return state;
}
};
export const RoutingContext = createContext<InitialStateType>(initialState);
const RoutingProvider = ({ children }: Props) => {
const [state, dispatch] = useReducer(reducer, initialState);
const value = {
redirectLocation: state.redirectLocation,
setRedirectLocation: (pathname: string | null) => {
dispatch({ type: actions.SET_REDIRECT_LOCATION, pathname });
},
};
return (
<RoutingContext.Provider value={value}>{children}</RoutingContext.Provider>
);
};
export default RoutingProvider;
+2 -2
View File
@@ -3,7 +3,7 @@ import md5 from "js-md5";
import { format, formatDistanceToNow, isAfter } from "date-fns";
import yaml from "js-yaml";
import { IConfigNested } from "interfaces/config";
import { IConfig } from "interfaces/config";
import { IHost } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IPack } from "interfaces/pack";
@@ -184,7 +184,7 @@ export const formatConfigDataForServer = (config: any): any => {
};
// TODO: Finalize interface for config - see frontend\interfaces\config.ts
export const frontendFormattedConfig = (config: IConfigNested) => {
export const frontendFormattedConfig = (config: IConfig) => {
const {
org_info: orgInfo,
server_settings: serverSettings,
+1 -1
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from "react";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { IOsqueryPlatform, SUPPORTED_PLATFORMS } from "interfaces/platform";
import checkPlatformCompatibility from "utilities/sql_tools";
+1 -68
View File
@@ -71,73 +71,6 @@ export default PropTypes.shape({
}),
});
export interface IConfig {
org_name: string;
org_logo_url: string;
server_url: string;
live_query_disabled: boolean;
enable_analytics: boolean;
enable_smtp: boolean;
configured: boolean;
sender_address: string;
server: string;
port: number;
authentication_type: string;
user_name: string;
password: string;
enable_ssl_tls: boolean;
authentication_method: string;
domain: string;
verify_sll_certs: boolean;
enable_start_tls: boolean;
entity_id: string;
issuer_uri: string;
idp_image_url: string;
metadata: string;
metadata_url: string;
idp_name: string;
enable_sso: boolean;
enable_sso_idp_login: boolean;
host_expiry_enabled: boolean;
host_expiry_window: number;
agent_options: string;
osquery_detail: number;
osquery_policy: number;
tier: string;
organization: string;
device_count: number;
expiration: string;
note: string;
// vulnerability_settings: any; TODO
enable_host_status_webhook: boolean;
destination_url: string;
host_percentage: number;
days_count: number;
debug: boolean;
json: boolean;
result: {
plugin: string;
config: {
status_log_file: string;
result_log_file: string;
enable_log_rotation: boolean;
enable_log_compression: boolean;
};
};
status: {
plugin: string;
config: {
status_log_file: string;
result_log_file: string;
enable_log_rotation: boolean;
enable_log_compression: boolean;
};
};
webhook_settings: {
failing_policies_webhook: IWebhookFailingPolicies;
};
}
export interface IConfigFormData {
smtpAuthenticationMethod: string;
smtpAuthenticationType: string;
@@ -173,7 +106,7 @@ export interface IConfigFormData {
enableUsageStatistics: boolean;
}
export interface IConfigNested {
export interface IConfig {
org_info: {
org_name: string;
org_logo_url: string;
-11
View File
@@ -1,11 +0,0 @@
import PropTypes from "prop-types";
export default PropTypes.shape({
action: PropTypes.string,
pathname: PropTypes.string,
});
export interface IRedirectLocation {
action: string;
pathname: string;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default PropTypes.shape({
build_user: PropTypes.string,
});
export interface IInvite {
export interface IVersionData {
version: string;
branch: string;
revision: string;
+5 -13
View File
@@ -1,11 +1,10 @@
import React, { useState, useContext } from "react";
import { InjectedRouter } from "react-router";
import { useDispatch } from "react-redux"; // @ts-ignore
import { logoutUser } from "redux/nodes/auth/actions";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { TableContext } from "context/table";
import paths from "router/paths";
import { useDeepEffect } from "utilities/hooks";
import FlashMessage from "components/FlashMessage";
import SiteTopNav from "components/top_nav/SiteTopNav";
@@ -33,11 +32,8 @@ const expirationMessage = (
);
const CoreLayout = ({ children, router }: ICoreLayoutProps) => {
const dispatch = useDispatch();
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const { notification, renderFlash, hideFlash } = useContext(
NotificationContext
);
const { notification, hideFlash } = useContext(NotificationContext);
const { setResetSelectedRows } = useContext(TableContext);
const [
showExpirationFlashMessage,
@@ -58,17 +54,13 @@ const CoreLayout = ({ children, router }: ICoreLayoutProps) => {
}
setShowExpirationFlashMessage(
licenseExpirationWarning(config?.expiration || "")
licenseExpirationWarning(config?.license.expiration || "")
);
}, [notification]);
const onLogoutUser = async () => {
try {
dispatch(logoutUser());
} catch (error) {
renderFlash("error", "Unable to log out of your account");
console.log(error);
}
const { LOGOUT } = paths;
router.push(LOGOUT);
};
const onNavItemClick = (path: string) => {
+27 -16
View File
@@ -1,28 +1,39 @@
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import { push } from "react-router-redux";
// @ts-ignore
import { fetchCurrentUser, logoutUser } from "redux/nodes/auth/actions";
import Button from "components/buttons/Button";
import { InjectedRouter } from "react-router";
import paths from "router/paths";
// @ts-ignore
import usersAPI from "services/entities/users";
import Button from "components/buttons/Button"; // @ts-ignore
import fleetLogoText from "../../../assets/images/fleet-logo-text-white.svg";
interface IApiOnlyUserProps {
router: InjectedRouter;
}
const baseClass = "api-only-user";
const ApiOnlyUser = (): JSX.Element => {
const dispatch = useDispatch();
const { LOGIN, HOME } = paths;
const handleClick = () => dispatch(logoutUser());
const ApiOnlyUser = ({ router }: IApiOnlyUserProps): JSX.Element => {
const { LOGIN, HOME, LOGOUT } = paths;
const handleClick = () => router.push(LOGOUT);
useEffect(() => {
dispatch(fetchCurrentUser()).then((user: any) => {
if (!user) {
dispatch(push(LOGIN));
} else if (user && !user.payload.user.api_only) {
dispatch(push(HOME));
const fetchCurrentUser = async () => {
try {
const { user } = await usersAPI.me();
if (!user) {
router.push(LOGIN);
} else if (!user?.api_only) {
router.push(HOME);
}
} catch (response) {
console.error(response);
return false;
}
});
};
fetchCurrentUser();
}, []);
return (
@@ -1,21 +1,21 @@
import React, { useContext, useState } from "react";
import React, { useContext, useState, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { Params } from "react-router/lib/Router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { ICreateUserWithInvitationFormData } from "interfaces/user";
import paths from "router/paths";
import usersAPI from "services/entities/users"; // @ts-ignore
import { formatErrorResponse } from "redux/nodes/entities/base/helpers";
import usersAPI from "services/entities/users";
import formatErrorResponse from "utilities/format_error_response";
// @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; // @ts-ignore
import ConfirmInviteForm from "components/forms/ConfirmInviteForm"; // @ts-ignore
import EnsureUnauthenticated from "components/EnsureUnauthenticated";
import ConfirmInviteForm from "components/forms/ConfirmInviteForm";
interface IConfirmInvitePageProps {
router: InjectedRouter; // v3
location: any; // no type in v3
location: any; // no type in react-router v3
params: Params;
}
@@ -29,9 +29,17 @@ const ConfirmInvitePage = ({
const { email, name } = location.query;
const { invite_token } = params;
const inviteFormData = { email, invite_token, name };
const { currentUser } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [userErrors, setUserErrors] = useState<any>({});
const { renderFlash } = useContext(NotificationContext);
useEffect(() => {
const { HOME } = paths;
if (currentUser) {
return router.push(HOME);
}
}, [currentUser]);
const onSubmit = async (formData: ICreateUserWithInvitationFormData) => {
const { create } = usersAPI;
@@ -73,4 +81,4 @@ const ConfirmInvitePage = ({
);
};
export default EnsureUnauthenticated(ConfirmInvitePage);
export default ConfirmInvitePage;
@@ -1,93 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
import ConfirmSSOInviteForm from "components/forms/ConfirmSSOInviteForm";
import EnsureUnauthenticated from "components/EnsureUnauthenticated";
import userActions from "redux/nodes/entities/users/actions";
import authActions from "redux/nodes/auth/actions";
import paths from "router/paths";
const baseClass = "confirm-invite-page";
class ConfirmSSOInvitePage extends Component {
static propTypes = {
dispatch: PropTypes.func,
inviteFormData: PropTypes.shape({
email: PropTypes.string.isRequired,
invite_token: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
}).isRequired,
userErrors: PropTypes.shape({
base: PropTypes.string,
}),
};
componentWillUnmount() {
const { dispatch } = this.props;
const { clearErrors } = userActions;
dispatch(clearErrors());
return false;
}
onSubmit = (formData) => {
const { create } = userActions;
const { ssoRedirect } = authActions;
const { dispatch } = this.props;
const { HOME } = paths;
formData.sso_invite = true;
dispatch(create(formData))
.then(() => {
// set redirect so that we will get redirected to home page after
// the user authenticates with the idp
dispatch(ssoRedirect(HOME))
.then((result) => {
window.location.href = result.payload.ssoRedirectURL;
})
.catch(() => false);
})
.catch(() => false);
return false;
};
render() {
const { inviteFormData, userErrors } = this.props;
const { onSubmit } = this;
return (
<AuthenticationFormWrapper>
<div className={`${baseClass}`}>
<div className={`${baseClass}__lead-wrapper`}>
<p className={`${baseClass}__lead-text`}>Welcome to Fleet</p>
<p className={`${baseClass}__sub-lead-text`}>
Before you get started, please take a moment to complete the
following information.
</p>
</div>
<ConfirmSSOInviteForm
className={`${baseClass}__form`}
formData={inviteFormData}
handleSubmit={onSubmit}
serverErrors={userErrors}
/>
</div>
</AuthenticationFormWrapper>
);
}
}
const mapStateToProps = (state, { location: urlLocation, params }) => {
const { email, name } = urlLocation.query;
const { invite_token: inviteToken } = params;
const inviteFormData = { email, invite_token: inviteToken, name };
const { errors: userErrors } = state.entities.users;
return { inviteFormData, userErrors };
};
const ConnectedComponent = connect(mapStateToProps)(ConfirmSSOInvitePage);
export default EnsureUnauthenticated(ConnectedComponent);
@@ -1,37 +0,0 @@
import { mount } from "enzyme";
import ConfirmSSOInvitePage from "pages/ConfirmSSOInvitePage";
import { connectedComponent, reduxMockStore } from "test/helpers";
describe("ConfirmSSOInvitePage - component", () => {
const inviteToken = "abc123";
const location = { query: { email: "hi@gnar.dog", name: "Gnar Dog" } };
const params = { invite_token: inviteToken };
const mockStore = reduxMockStore({ auth: {}, entities: { users: {} } });
const component = connectedComponent(ConfirmSSOInvitePage, {
props: { location, params },
mockStore,
});
const page = mount(component);
it("renders", () => {
expect(page.length).toEqual(1);
expect(page.find("ConfirmSSOInvitePage").prop("inviteFormData")).toEqual({
email: "hi@gnar.dog",
invite_token: inviteToken,
name: "Gnar Dog",
});
});
it("renders a ConfirmSSOInviteForm", () => {
expect(page.find("ConfirmSSOInviteForm").length).toEqual(1);
});
it("clears errors on unmount", () => {
page.unmount();
expect(mockStore.getActions()).toContainEqual({
type: "users_CLEAR_ERRORS",
});
});
});
@@ -0,0 +1,79 @@
import React, { useState, useEffect, useContext } from "react";
import { InjectedRouter } from "react-router";
import { Params } from "react-router/lib/Router";
import paths from "router/paths";
import { AppContext } from "context/app";
import usersAPI from "services/entities/users";
import sessionsAPI from "services/entities/sessions";
import formatErrorResponse from "utilities/format_error_response";
// @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; // @ts-ignore
import ConfirmSSOInviteForm from "components/forms/ConfirmSSOInviteForm";
interface IConfirmSSOInvitePageProps {
location: any; // no type in react-router v3
params: Params;
router: InjectedRouter;
}
const baseClass = "confirm-invite-page";
const ConfirmSSOInvitePage = ({
location,
params,
router,
}: IConfirmSSOInvitePageProps) => {
const { email, name } = location.query;
const { invite_token } = params;
const inviteFormData = { email, invite_token, name };
const { currentUser } = useContext(AppContext);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
const { HOME } = paths;
if (currentUser) {
return router.push(HOME);
}
}, [currentUser]);
const onSubmit = async (formData: any) => {
const { HOME } = paths;
formData.sso_invite = true;
try {
await usersAPI.create(formData);
const { url } = await sessionsAPI.initializeSSO(HOME);
window.location.href = url;
} catch (response) {
const errorObject = formatErrorResponse(response);
setErrors(errorObject);
return false;
}
};
return (
<AuthenticationFormWrapper>
<div className={`${baseClass}`}>
<div className={`${baseClass}__lead-wrapper`}>
<p className={`${baseClass}__lead-text`}>Welcome to Fleet</p>
<p className={`${baseClass}__sub-lead-text`}>
Before you get started, please take a moment to complete the
following information.
</p>
</div>
<ConfirmSSOInviteForm
className={`${baseClass}__form`}
formData={inviteFormData}
handleSubmit={onSubmit}
serverErrors={errors}
/>
</div>
</AuthenticationFormWrapper>
);
};
export default ConfirmSSOInvitePage;
@@ -1,20 +0,0 @@
import React from "react";
import { mount } from "enzyme";
import { ForgotPasswordPage } from "./ForgotPasswordPage";
describe("ForgotPasswordPage - component", () => {
it("renders the ForgotPasswordForm when there is no email prop", () => {
const page = mount(<ForgotPasswordPage />);
expect(page.find("ForgotPasswordForm").length).toEqual(1);
});
it("renders the email sent text when the email state is present", () => {
const email = "hi@thegnar.co";
const page = mount(<ForgotPasswordPage />).setState({ email });
expect(page.find("ForgotPasswordForm").length).toEqual(0);
expect(page.text()).toContain(`An email was sent to ${email}.`);
});
});
@@ -1,47 +1,36 @@
import React, { Component } from "react";
import React, { useEffect, useState } from "react";
import PATHS from "router/paths";
import usersAPI from "services/entities/users";
import { formatErrorResponse } from "redux/nodes/entities/base/helpers";
import debounce from "utilities/debounce";
import ForgotPasswordForm from "components/forms/ForgotPasswordForm";
import StackedWhiteBoxes from "components/StackedWhiteBoxes";
import formatErrorResponse from "utilities/format_error_response"; // @ts-ignore
import ForgotPasswordForm from "components/forms/ForgotPasswordForm"; // @ts-ignore
import StackedWhiteBoxes from "components/StackedWhiteBoxes"; // @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
export class ForgotPasswordPage extends Component {
constructor() {
super();
const ForgotPasswordPage = () => {
const [email, setEmail] = useState<string>("");
const [errors, setErrors] = useState<{ [key: string]: string }>({});
this.state = {
email: null,
errors: {},
};
}
useEffect(() => {
setErrors({});
}, []);
componentWillUnmount() {
return this.clearErrors();
}
handleSubmit = debounce(async (formData) => {
const handleSubmit = async (formData: any) => {
try {
await usersAPI.forgotPassword(formData);
const { email } = formData;
this.setState({ email, errors: {} });
setEmail(formData.email);
setErrors({});
} catch (response) {
const errorObject = formatErrorResponse(response);
this.setState({ email: null, errors: errorObject });
setEmail("");
setErrors(errorObject);
return false;
}
});
clearErrors = () => {
this.setState({ errors: {} });
};
renderContent = () => {
const { clearErrors, handleSubmit } = this;
const { email, errors } = this.state;
const renderContent = () => {
const baseClass = "forgot-password";
if (email) {
@@ -61,26 +50,26 @@ export class ForgotPasswordPage extends Component {
return (
<ForgotPasswordForm
handleSubmit={handleSubmit}
onChangeFunc={clearErrors}
onChangeFunc={() => setErrors({})}
serverErrors={errors}
/>
);
};
render() {
const leadText =
"Enter your email below and we will email you a link so that you can reset your password.";
const leadText =
"Enter your email below and we will email you a link so that you can reset your password.";
return (
return (
<AuthenticationFormWrapper>
<StackedWhiteBoxes
leadText={leadText}
previousLocation={PATHS.LOGIN}
className="forgot-password"
>
{this.renderContent()}
{renderContent()}
</StackedWhiteBoxes>
);
}
}
</AuthenticationFormWrapper>
);
};
export default ForgotPasswordPage;
+2 -3
View File
@@ -12,8 +12,7 @@ import sortUtils from "utilities/sort";
import { PLATFORM_DROPDOWN_OPTIONS } from "utilities/constants";
import TeamsDropdown from "components/TeamsDropdown";
import Spinner from "components/Spinner";
// @ts-ignore
import Spinner from "components/Spinner"; // @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import useInfoCard from "./components/InfoCard";
import HostsStatus from "./cards/HostsStatus";
@@ -297,7 +296,7 @@ const Homepage = (): JSX.Element => {
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>
{isFreeTier && <h1>{config?.org_name}</h1>}
{isFreeTier && <h1>{config?.org_info.org_name}</h1>}
{isPremiumTier &&
teams &&
(teams.length > 1 || isOnGlobalTeam) && (
@@ -1,41 +0,0 @@
import { render, screen } from "@testing-library/react";
import { connectedComponent, reduxMockStore } from "../../test/helpers";
import LoginPage from "./LoginPage";
const ssoSettings = { sso_enabled: false };
describe("LoginPage - component", () => {
describe("when the user is not logged in", () => {
const mockStore = reduxMockStore({ auth: { ssoSettings } });
it("renders the LoginForm", () => {
const { container } = render(
connectedComponent(LoginPage, { mockStore })
);
expect(container.querySelectorAll(".login-form").length).toEqual(1);
});
});
describe("when the users session is not recognized", () => {
const mockStore = reduxMockStore({
auth: {
errors: { base: "Unable to authenticate the current user" },
ssoSettings,
},
});
it("renders the LoginForm base errors", () => {
const { container } = render(
connectedComponent(LoginPage, { mockStore })
);
const loginForm = container.querySelectorAll(".login-form");
expect(loginForm.length).toEqual(1);
expect(
screen.getByText("Unable to authenticate the current user")
).toBeInTheDocument();
});
});
});
+69 -97
View File
@@ -1,123 +1,110 @@
import React, { useState, useEffect, useContext } from "react";
import { InjectedRouter } from "react-router";
import { connect } from "react-redux";
import { size } from "lodash";
import { Dispatch } from "redux";
import paths from "router/paths";
import { AppContext } from "context/app";
import { RoutingContext } from "context/routing";
import { ISSOSettings } from "interfaces/ssoSettings";
import local from "utilities/local";
import sessionsAPI from "services/entities/sessions";
import formatErrorResponse from "utilities/format_error_response";
// @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
import {
clearAuthErrors,
loginUser,
ssoRedirect, // @ts-ignore
} from "redux/nodes/auth/actions"; // @ts-ignore
import { clearRedirectLocation } from "redux/nodes/redirectLocation/actions"; // @ts-ignore
import debounce from "utilities/debounce"; // @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; // @ts-ignore
import LoginForm from "components/forms/LoginForm"; // @ts-ignore
import LoginSuccessfulPage from "pages/LoginSuccessfulPage"; // @ts-ignore
import ForgotPasswordPage from "pages/ForgotPasswordPage"; // @ts-ignore
import ResetPasswordPage from "pages/ResetPasswordPage";
import paths from "router/paths";
import { IRedirectLocation } from "interfaces/redirect_location";
import { IUser } from "interfaces/user";
import { ISSOSettings } from "interfaces/ssoSettings";
import { ITeamSummary } from "interfaces/team";
import { AppContext } from "context/app";
interface ILoginPageProps {
dispatch: Dispatch;
errors: {
base: string;
};
pathname: string;
isForgotPassPage: boolean;
isResetPassPage: boolean;
token: string;
redirectLocation: IRedirectLocation;
user: IUser;
ssoSettings: ISSOSettings;
router: InjectedRouter; // v3
}
export interface ILoginUserResponse {
user: IUser;
available_teams: ITeamSummary[];
interface ILoginData {
email: string;
password: string;
}
const LoginPage = ({
dispatch,
errors,
pathname,
isForgotPassPage,
isResetPassPage,
token,
redirectLocation,
user,
ssoSettings,
router,
}: ILoginPageProps) => {
const { setAvailableTeams, setCurrentUser, setCurrentTeam } = useContext(
AppContext
);
const LoginPage = ({ router }: ILoginPageProps) => {
const {
currentUser,
setAvailableTeams,
setCurrentUser,
setCurrentTeam,
} = useContext(AppContext);
const { redirectLocation } = useContext(RoutingContext);
const [loginVisible, setLoginVisible] = useState<boolean>(true);
const [ssoSettings, setSSOSettings] = useState<ISSOSettings>();
const [errors, setErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
const { HOME, LOGIN } = paths;
const { HOME } = paths;
const getSSO = async () => {
try {
const { settings } = await sessionsAPI.ssoSettings();
setSSOSettings(settings);
} catch (error) {
console.error(error);
return false;
}
};
if (user && pathname === LOGIN) {
if (currentUser) {
router?.push(HOME);
} else {
getSSO();
}
}, [router]);
const onChange = () => {
if (size(errors)) {
return dispatch(clearAuthErrors);
setErrors({});
}
return false;
};
const onSubmit = debounce((formData: any) => {
const { HOME } = paths;
const redirectTime = 1500;
return dispatch(loginUser(formData))
.then(({ user: returnedUser, available_teams }: ILoginUserResponse) => {
setLoginVisible(false);
const onSubmit = async (formData: ILoginData) => {
const { HOME, RESET_PASSWORD } = paths;
// Redirect to password reset page if user is forced to reset password.
// Any other requests will fail.
if (returnedUser.force_password_reset) {
return router.push(paths.RESET_PASSWORD);
}
try {
const { user, available_teams, token } = await sessionsAPI.create(
formData
);
local.setItem("auth_token", token);
// transitioning to context API - 9/1/21 MP
setCurrentUser(returnedUser);
setAvailableTeams(available_teams);
setLoginVisible(false);
setCurrentUser(user);
setAvailableTeams(available_teams);
setCurrentTeam(undefined);
// Ensure team is undefined on login
setCurrentTeam(undefined);
// Redirect to password reset page if user is forced to reset password.
// Any other requests will fail.
if (user.force_password_reset) {
return router.push(RESET_PASSWORD);
}
setTimeout(() => {
const nextLocation = redirectLocation || HOME;
dispatch(clearRedirectLocation);
return router.push(nextLocation);
}, redirectTime);
})
.catch(() => false);
});
return router.push(redirectLocation || HOME);
} catch (response) {
const errorObject = formatErrorResponse(response);
setErrors(errorObject);
return false;
}
};
const ssoSignOn = () => {
const ssoSignOn = async () => {
const { HOME } = paths;
let returnToAfterAuth = HOME;
if (redirectLocation != null) {
returnToAfterAuth = redirectLocation.pathname;
returnToAfterAuth = redirectLocation;
}
dispatch(ssoRedirect(returnToAfterAuth))
.then((result: any) => {
window.location.href = result.payload.ssoRedirectURL;
})
.catch(() => false);
try {
const { url } = await sessionsAPI.initializeSSO(returnToAfterAuth);
window.location.href = url;
} catch (error) {
console.error(error);
return false;
}
};
return (
@@ -131,23 +118,8 @@ const LoginPage = ({
ssoSettings={ssoSettings}
handleSSOSignOn={ssoSignOn}
/>
{isForgotPassPage && <ForgotPasswordPage />}
{isResetPassPage && <ResetPasswordPage token={token} />}
</AuthenticationFormWrapper>
);
};
const mapStateToProps = (state: any) => {
const { errors, loading, user, ssoSettings } = state.auth;
const { redirectLocation } = state;
return {
errors,
loading,
redirectLocation,
user,
ssoSettings,
};
};
export default connect(mapStateToProps)(LoginPage);
export default LoginPage;
@@ -0,0 +1,69 @@
import React, { useState, useEffect, useContext } from "react";
import { InjectedRouter } from "react-router";
import paths from "router/paths";
import { AppContext } from "context/app"; // @ts-ignore
import sessionsAPI from "services/entities/sessions";
import local from "utilities/local";
// @ts-ignore
import LoginSuccessfulPage from "pages/LoginSuccessfulPage"; // @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; // @ts-ignore
import LoginForm from "components/forms/LoginForm"; // @ts-ignore
interface ILoginPreviewPageProps {
router: InjectedRouter; // v3
}
interface ILoginData {
email: string;
password: string;
}
const LoginPreviewPage = ({ router }: ILoginPreviewPageProps): JSX.Element => {
const {
isPreviewMode,
setAvailableTeams,
setCurrentUser,
setCurrentTeam,
} = useContext(AppContext);
const [loginVisible, setLoginVisible] = useState<boolean>(true);
const onSubmit = async (formData: ILoginData) => {
const { HOME } = paths;
try {
const { user, available_teams, token } = await sessionsAPI.create(
formData
);
local.setItem("auth_token", token);
setLoginVisible(false);
setCurrentUser(user);
setAvailableTeams(available_teams);
setCurrentTeam(undefined);
return router.push(HOME);
} catch (response) {
console.error(response);
return false;
}
};
useEffect(() => {
if (isPreviewMode) {
onSubmit({
email: "admin@example.com",
password: "admin123#",
});
}
}, []);
return (
<AuthenticationFormWrapper>
<LoginSuccessfulPage />
<LoginForm handleSubmit={onSubmit} isHidden={!loginVisible} />
</AuthenticationFormWrapper>
);
};
export default LoginPreviewPage;
@@ -1,66 +0,0 @@
import React, { useState, useEffect, useContext } from "react";
import { useDispatch } from "react-redux";
import { push } from "react-router-redux";
import paths from "router/paths";
import { AppContext } from "context/app";
// @ts-ignore
import { loginUser } from "redux/nodes/auth/actions";
// @ts-ignore
import debounce from "utilities/debounce";
// @ts-ignore
import LoginSuccessfulPage from "pages/LoginSuccessfulPage"; // @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; // @ts-ignore
import LoginForm from "components/forms/LoginForm"; // @ts-ignore
import { ILoginUserResponse } from "./LoginPage";
interface ILoginData {
email: string;
password: string;
}
const PreviewLoginPage = (): JSX.Element => {
const dispatch = useDispatch();
const { isPreviewMode, setAvailableTeams, setCurrentUser } = useContext(
AppContext
);
const [loginVisible, setLoginVisible] = useState<boolean>(true);
const onSubmit = debounce((formData: ILoginData) => {
const { HOME } = paths;
const redirectTime = 1500;
return dispatch(loginUser(formData))
.then(({ user: returnedUser, available_teams }: ILoginUserResponse) => {
setLoginVisible(false);
// transitioning to context API - 9/1/21 MP
setCurrentUser(returnedUser);
setAvailableTeams(available_teams);
setTimeout(() => {
return dispatch(push(HOME));
}, redirectTime);
})
.catch(() => false);
});
useEffect(() => {
if (isPreviewMode) {
onSubmit({
email: "admin@example.com",
password: "admin123#",
});
}
}, []);
return (
<AuthenticationFormWrapper>
<LoginSuccessfulPage />
<LoginForm handleSubmit={onSubmit} isHidden={!loginVisible} />
</AuthenticationFormWrapper>
);
};
export default PreviewLoginPage;
+2 -2
View File
@@ -1,4 +1,4 @@
import LoginPage from "./LoginPage";
import PreviewLoginPage from "./PreviewLoginPage";
import LoginPreviewPage from "./LoginPreviewPage";
export { LoginPage as default, PreviewLoginPage };
export { LoginPage as default, LoginPreviewPage };
+32 -5
View File
@@ -1,9 +1,36 @@
// @ts-ignore
import { clearToken } from "../../utilities/local"; // @ts-ignore
import { useContext, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { NotificationContext } from "context/notification";
import sessionsAPI from "services/entities/sessions";
import { clearToken } from "utilities/local";
interface ILogoutPageProps {
router: InjectedRouter;
}
const LogoutPage = ({ router }: ILogoutPageProps): boolean => {
const { renderFlash } = useContext(NotificationContext);
useEffect(() => {
const logoutUser = async () => {
try {
await sessionsAPI.destroy();
clearToken();
setTimeout(() => {
window.location.href = "/";
}, 500);
} catch (response) {
console.error(response);
router.goBack();
renderFlash("error", "Unable to log out of your account");
return false;
}
};
logoutUser();
}, []);
const LogoutPage = (): boolean => {
clearToken();
window.location.href = "/";
return false;
};
@@ -1,104 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { max, noop } from "lodash";
import { push } from "react-router-redux";
import Breadcrumbs from "pages/RegistrationPage/Breadcrumbs";
import paths from "router/paths";
import RegistrationForm from "components/forms/RegistrationForm";
import { setup } from "redux/nodes/auth/actions";
import { showBackgroundImage } from "redux/nodes/app/actions";
import EnsureUnauthenticated from "components/EnsureUnauthenticated";
import fleetLogoText from "../../../assets/images/fleet-logo-text-white.svg";
export class RegistrationPage extends Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
};
static defaultProps = {
dispatch: noop,
};
constructor(props) {
super(props);
this.state = {
page: 1,
pageProgress: 1,
};
}
componentWillMount() {
const { dispatch } = this.props;
dispatch(showBackgroundImage);
return false;
}
onNextPage = () => {
const { page, pageProgress } = this.state;
const nextPage = page + 1;
this.setState({
page: nextPage,
pageProgress: max([nextPage, pageProgress]),
});
return false;
};
onRegistrationFormSubmit = (formData) => {
const { dispatch } = this.props;
const { MANAGE_HOSTS } = paths;
return dispatch(setup(formData))
.then(() => {
return dispatch(push(MANAGE_HOSTS));
})
.catch(() => {
return false;
});
};
onSetPage = (page) => {
const { pageProgress } = this.state;
if (page > pageProgress) {
return false;
}
this.setState({ page });
return false;
};
render() {
const { page, pageProgress } = this.state;
const { onRegistrationFormSubmit, onNextPage, onSetPage } = this;
return (
<div className="registration-page">
<img
alt="Fleet logo"
src={fleetLogoText}
className="registration-page__logo"
/>
<Breadcrumbs
onClick={onSetPage}
page={page}
pageProgress={pageProgress}
/>
<RegistrationForm
page={page}
onNextPage={onNextPage}
onSubmit={onRegistrationFormSubmit}
/>
</div>
);
}
}
const ConnectedComponent = connect()(RegistrationPage);
export default EnsureUnauthenticated(ConnectedComponent);
@@ -1,97 +0,0 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import paths from "router/paths";
import { connectedComponent, reduxMockStore } from "test/helpers";
import ConnectedRegistrationPage, {
RegistrationPage,
} from "pages/RegistrationPage/RegistrationPage";
const baseStore = {
app: {},
auth: {},
};
const user = {
id: 1,
name: "Gnar Dog",
email: "hi@gnar.dog",
};
describe("RegistrationPage - component", () => {
it("redirects to the home page when a user is logged in", () => {
const storeWithUser = {
...baseStore,
auth: {
loading: false,
user,
},
};
const mockStore = reduxMockStore(storeWithUser);
render(connectedComponent(ConnectedRegistrationPage, { mockStore }));
const dispatchedActions = mockStore.getActions();
const redirectToHomeAction = {
type: "@@router/CALL_HISTORY_METHOD",
payload: {
method: "push",
args: [paths.HOME],
},
};
expect(dispatchedActions).toContainEqual(redirectToHomeAction);
});
it("displays the Fleet background triangles", () => {
const mockStore = reduxMockStore(baseStore);
render(connectedComponent(ConnectedRegistrationPage, { mockStore }));
expect(mockStore.getActions()).toContainEqual({
type: "SHOW_BACKGROUND_IMAGE",
});
});
it("does not render the RegistrationForm if the user is loading", () => {
const mockStore = reduxMockStore({
app: {},
auth: { loading: true },
});
const { container } = render(
connectedComponent(ConnectedRegistrationPage, { mockStore })
);
expect(container.querySelectorAll(".user-registration").length).toEqual(0);
});
it("renders the RegistrationForm when there is no user", () => {
const mockStore = reduxMockStore(baseStore);
const { container } = render(
connectedComponent(ConnectedRegistrationPage, { mockStore })
);
expect(container.querySelectorAll(".user-registration").length).toEqual(1);
});
it("sets the page number to 1", () => {
render(<RegistrationPage />);
expect(
screen.getByRole("heading", { name: "Setup user" })
).toBeInTheDocument();
});
it("displays the setup breadcrumbs", () => {
const mockStore = reduxMockStore(baseStore);
const { container } = render(
connectedComponent(ConnectedRegistrationPage, { mockStore })
);
expect(
container.querySelectorAll(".registration-breadcrumbs").length
).toEqual(1);
});
});
@@ -0,0 +1,86 @@
import React, { useContext, useState, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { max } from "lodash";
import paths from "router/paths"; // @ts-ignore
import { AppContext } from "context/app";
import usersAPI from "services/entities/users";
import local from "utilities/local";
// @ts-ignore
import RegistrationForm from "components/forms/RegistrationForm"; // @ts-ignore
import Breadcrumbs from "./Breadcrumbs"; // @ts-ignore
import fleetLogoText from "../../../assets/images/fleet-logo-text-white.svg";
interface IRegistrationPageProps {
router: InjectedRouter;
}
const RegistrationPage = ({ router }: IRegistrationPageProps) => {
const { currentUser, setCurrentUser, setAvailableTeams } = useContext(
AppContext
);
const [page, setPage] = useState<number>(1);
const [pageProgress, setPageProgress] = useState<number>(1);
useEffect(() => {
const { HOME } = paths;
if (currentUser) {
return router.push(HOME);
}
}, [currentUser]);
const onNextPage = () => {
const nextPage = page + 1;
setPage(nextPage);
setPageProgress(max([nextPage, pageProgress]) || 1);
};
const onRegistrationFormSubmit = async (formData: any) => {
const { MANAGE_HOSTS } = paths;
try {
const { token } = await usersAPI.setup(formData);
local.setItem("auth_token", token);
const { user, available_teams } = await usersAPI.me();
setCurrentUser(user);
setAvailableTeams(available_teams);
return router.push(MANAGE_HOSTS);
} catch (response) {
console.error(response);
return false;
}
};
const onSetPage = (pageNum: number) => {
if (pageNum > pageProgress) {
return false;
}
setPage(pageNum);
};
return (
<div className="registration-page">
<img
alt="Fleet logo"
src={fleetLogoText}
className="registration-page__logo"
/>
<Breadcrumbs
onClick={onSetPage}
page={page}
pageProgress={pageProgress}
/>
<RegistrationForm
page={page}
onNextPage={onNextPage}
onSubmit={onRegistrationFormSubmit}
/>
</div>
);
};
export default RegistrationPage;
@@ -1,111 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { noop, size } from "lodash";
import { push } from "react-router-redux";
import debounce from "utilities/debounce";
import {
clearResetPasswordErrors,
resetPassword,
} from "redux/nodes/components/ResetPasswordPage/actions";
import ResetPasswordForm from "components/forms/ResetPasswordForm";
import StackedWhiteBoxes from "components/StackedWhiteBoxes";
import { performRequiredPasswordReset } from "redux/nodes/auth/actions";
import userInterface from "interfaces/user";
import PATHS from "router/paths";
export class ResetPasswordPage extends Component {
static propTypes = {
dispatch: PropTypes.func,
errors: PropTypes.shape({
base: PropTypes.string,
new_password: PropTypes.string,
}),
token: PropTypes.string,
user: userInterface,
};
static defaultProps = {
dispatch: noop,
};
componentWillMount() {
const { dispatch, token, user } = this.props;
if (!user && !token) {
return dispatch(push(PATHS.LOGIN));
}
return false;
}
onResetErrors = () => {
const { dispatch, errors } = this.props;
if (size(errors)) {
dispatch(clearResetPasswordErrors);
}
return false;
};
onSubmit = debounce((formData) => {
const { dispatch, token, user } = this.props;
if (user) {
return this.loggedInUser(formData);
}
const resetPasswordData = {
...formData,
password_reset_token: token,
};
return dispatch(resetPassword(resetPasswordData))
.then(() => {
return dispatch(push(PATHS.LOGIN));
})
.catch(() => false);
});
loggedInUser = (formData) => {
const { dispatch } = this.props;
const { new_password: password } = formData;
const passwordUpdateParams = { password };
return dispatch(performRequiredPasswordReset(passwordUpdateParams))
.then(() => {
return dispatch(push(PATHS.HOME));
})
.catch(() => false);
};
render() {
const { handleLeave, onResetErrors, onSubmit } = this;
const { errors } = this.props;
return (
<StackedWhiteBoxes leadText="Create a new password. Your new password must include 7 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)">
<ResetPasswordForm
handleSubmit={onSubmit}
onChangeFunc={onResetErrors}
serverErrors={errors}
/>
</StackedWhiteBoxes>
);
}
}
const mapStateToProps = (state) => {
const { ResetPasswordPage: componentState } = state.components;
const { user, errors } = state.auth;
return {
...componentState,
user,
errors,
};
};
export default connect(mapStateToProps)(ResetPasswordPage);
@@ -1,42 +0,0 @@
import React from "react";
import { render } from "@testing-library/react";
import ConnectedPage, { ResetPasswordPage } from "./ResetPasswordPage";
import testHelpers from "../../test/helpers";
describe("ResetPasswordPage - component", () => {
it("renders a ResetPasswordForm", () => {
const { container } = render(<ResetPasswordPage token="ABC123" />);
expect(container.querySelectorAll(".reset-password-form").length).toEqual(
1
);
});
it("Redirects to the login page when there is no token or user", () => {
const { connectedComponent, reduxMockStore } = testHelpers;
const redirectToLoginAction = {
type: "@@router/CALL_HISTORY_METHOD",
payload: {
method: "push",
args: ["/login"],
},
};
const store = {
auth: {},
components: {
ResetPasswordPage: {
loading: false,
error: null,
},
},
};
const mockStore = reduxMockStore(store);
render(connectedComponent(ConnectedPage, { mockStore }));
const dispatchedActions = mockStore.getActions();
expect(dispatchedActions).toContainEqual(redirectToLoginAction);
});
});
@@ -0,0 +1,86 @@
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";
import usersAPI from "services/entities/users";
import configAPI from "services/entities/config";
import formatErrorResponse from "utilities/format_error_response";
// @ts-ignore
import ResetPasswordForm from "components/forms/ResetPasswordForm"; // @ts-ignore
import StackedWhiteBoxes from "components/StackedWhiteBoxes"; // @ts-ignore
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
interface IResetPasswordPageProps {
location: any; // no type in react-router v3
router: InjectedRouter;
}
const ResetPasswordPage = ({ location, router }: IResetPasswordPageProps) => {
const { token } = location.query;
const { currentUser, setConfig } = useContext(AppContext);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
if (!currentUser && !token) {
router.push(PATHS.LOGIN);
}
}, [currentUser, token]);
const onResetErrors = () => {
if (size(errors)) {
setErrors({});
}
};
const continueWithLoggedInUser = async (formData: any) => {
const { new_password } = formData;
try {
await usersAPI.performRequiredPasswordReset(new_password as string);
const config = await configAPI.loadAll();
setConfig(config);
return router.push(PATHS.HOME);
} catch (response) {
const errorObject = formatErrorResponse(response);
setErrors(errorObject);
return false;
}
};
const onSubmit = async (formData: any) => {
if (currentUser) {
return continueWithLoggedInUser(formData);
}
const resetPasswordData = {
...formData,
password_reset_token: token,
};
try {
await usersAPI.resetPassword(resetPasswordData);
router.push(PATHS.LOGIN);
} catch (response) {
const errorObject = formatErrorResponse(response);
setErrors(errorObject);
return false;
}
};
return (
<AuthenticationFormWrapper>
<StackedWhiteBoxes leadText="Create a new password. Your new password must include 7 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)">
<ResetPasswordForm
handleSubmit={onSubmit}
onChangeFunc={onResetErrors}
serverErrors={errors}
/>
</StackedWhiteBoxes>
</AuthenticationFormWrapper>
);
};
export default ResetPasswordPage;
@@ -1,450 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { goBack } from "react-router-redux";
import { formatDistanceToNow } from "date-fns";
import { authToken } from "utilities/local";
import { stringToClipboard } from "utilities/copy_text";
import { noop } from "lodash";
import Avatar from "components/Avatar";
import Button from "components/buttons/Button";
import ChangeEmailForm from "components/forms/ChangeEmailForm";
import ChangePasswordForm from "components/forms/ChangePasswordForm";
import deepDifference from "utilities/deep_difference";
import permissionUtils from "utilities/permissions";
import FleetIcon from "components/icons/FleetIcon";
import InputField from "components/forms/fields/InputField";
import { logoutUser, updateUser } from "redux/nodes/auth/actions";
import Modal from "components/Modal";
import configInterface from "interfaces/config";
import versionInterface from "interfaces/version";
import { renderFlash } from "redux/nodes/notifications/actions";
import userActions from "redux/nodes/entities/users/actions";
import versionActions from "redux/nodes/version/actions";
import userInterface from "interfaces/user";
import UserSettingsForm from "components/forms/UserSettingsForm";
import { generateRole, generateTeam, greyCell } from "fleet/helpers";
const baseClass = "user-settings";
export class UserSettingsPage extends Component {
static propTypes = {
config: configInterface,
dispatch: PropTypes.func.isRequired,
version: versionInterface,
errors: PropTypes.shape({
email: PropTypes.string,
base: PropTypes.string,
}),
user: userInterface,
userErrors: PropTypes.shape({
base: PropTypes.string,
new_password: PropTypes.string,
old_password: PropTypes.string,
}),
isPremiumTier: PropTypes.bool,
};
static defaultProps = {
version: {},
dispatch: noop,
};
constructor(props) {
super(props);
this.state = {
pendingEmail: undefined,
showEmailModal: false,
showPasswordModal: false,
updatedUser: {},
copyMessage: "",
};
}
componentDidMount() {
const { dispatch } = this.props;
dispatch(versionActions.getVersion());
return false;
}
onCancel = (evt) => {
evt.preventDefault();
const { dispatch } = this.props;
dispatch(goBack());
return false;
};
onLogout = (evt) => {
evt.preventDefault();
const { dispatch } = this.props;
dispatch(logoutUser());
return false;
};
onShowModal = (evt) => {
evt.preventDefault();
this.setState({ showPasswordModal: true });
return false;
};
onShowApiTokenModal = (evt) => {
evt.preventDefault();
this.setState({ showApiTokenModal: true });
return false;
};
onToggleEmailModal = (updatedUser = {}) => {
const { showEmailModal } = this.state;
this.setState({
showEmailModal: !showEmailModal,
updatedUser,
});
return false;
};
onTogglePasswordModal = (evt) => {
evt.preventDefault();
const { showPasswordModal } = this.state;
this.setState({ showPasswordModal: !showPasswordModal });
return false;
};
onToggleApiTokenModal = (evt) => {
evt.preventDefault();
const { showApiTokenModal } = this.state;
this.setState({ showApiTokenModal: !showApiTokenModal });
return false;
};
onToggleSecret = (evt) => {
evt.preventDefault();
const { revealSecret } = this.state;
this.setState({ revealSecret: !revealSecret });
return false;
};
onCopySecret = () => {
return (evt) => {
evt.preventDefault();
stringToClipboard(authToken())
.then(() => this.setState({ copyMessage: "Copied!" }))
.catch(() => this.setState({ copyMessage: "Copy failed" }));
// Clear message after 1 second
setTimeout(() => this.setState({ copyMessage: "" }), 1000);
return false;
};
};
handleSubmit = (formData) => {
const { dispatch, user, config } = this.props;
const updatedUser = deepDifference(formData, user);
if (updatedUser.email && !updatedUser.password) {
return this.onToggleEmailModal(updatedUser);
}
return dispatch(updateUser(user, updatedUser))
.then(() => {
let accountUpdatedFlashMessage = "Account updated";
if (updatedUser.email) {
accountUpdatedFlashMessage += `: A confirmation email was sent from ${config.sender_address} to ${updatedUser.email}`;
this.setState({ pendingEmail: updatedUser.email });
}
dispatch(renderFlash("success", accountUpdatedFlashMessage));
return true;
})
.catch((userErrors) => {
if (userErrors.base.includes("already exists")) {
// TODO: Revamp to create inline error requires jsx > tsx / form fields state/validators
dispatch(
renderFlash(
"error",
"A user with this email address already exists."
)
);
} else {
dispatch(
renderFlash("error", "Could not edit user. Please try again.")
);
}
this.setState({ showEmailModal: false });
});
};
handleSubmitPasswordForm = (formData) => {
const { dispatch, user } = this.props;
return dispatch(userActions.changePassword(user, formData)).then(() => {
dispatch(renderFlash("success", "Password changed successfully"));
this.setState({ showPasswordModal: false });
return false;
});
};
renderEmailModal = () => {
const { errors } = this.props;
const { updatedUser, showEmailModal } = this.state;
const { handleSubmit, onToggleEmailModal } = this;
const emailSubmit = (formData) => {
handleSubmit(formData).then((r) => {
return r ? onToggleEmailModal() : false;
});
};
if (!showEmailModal) {
return false;
}
return (
<Modal title="Confirm email update" onExit={onToggleEmailModal}>
<div className={`${baseClass}__confirm-update`}>
To update your email you must confirm your password.
</div>
<ChangeEmailForm
formData={updatedUser}
handleSubmit={emailSubmit}
onCancel={onToggleEmailModal}
serverErrors={errors}
/>
</Modal>
);
};
renderPasswordModal = () => {
const { userErrors } = this.props;
const { showPasswordModal } = this.state;
const { handleSubmitPasswordForm, onTogglePasswordModal } = this;
if (!showPasswordModal) {
return false;
}
return (
<Modal title="Change password" onExit={onTogglePasswordModal}>
<ChangePasswordForm
handleSubmit={handleSubmitPasswordForm}
onCancel={onTogglePasswordModal}
serverErrors={userErrors}
/>
</Modal>
);
};
renderLabel = () => {
const { copyMessage } = this.state;
const { onCopySecret } = this;
return (
<span className={`${baseClass}__name`}>
<span className="buttons">
{copyMessage && <span>{`${copyMessage} `}</span>}
<Button
variant="unstyled"
className={`${baseClass}__secret-copy-icon`}
onClick={onCopySecret(`.${baseClass}__secret-input`)}
>
<FleetIcon name="clipboard" />
</Button>
</span>
</span>
);
};
renderApiTokenModal = () => {
const { showApiTokenModal, revealSecret } = this.state;
const { onToggleApiTokenModal, onToggleSecret, renderLabel } = this;
if (!showApiTokenModal) {
return false;
}
return (
<Modal title="Get API token" onExit={onToggleApiTokenModal}>
<p className={`${baseClass}__secret-label`}>
Your API token:
<a
href="#revealSecret"
onClick={onToggleSecret}
className={`${baseClass}__reveal-secret`}
>
{revealSecret ? "Hide" : "Reveal"} Token
</a>
</p>
<div className={`${baseClass}__secret-wrapper`}>
<InputField
disabled
inputWrapperClass={`${baseClass}__secret-input`}
name="osqueryd-secret"
type={revealSecret ? "text" : "password"}
value={authToken()}
label={renderLabel()}
/>
</div>
<div className={`${baseClass}__button-wrap`}>
<Button
onClick={onToggleApiTokenModal}
className="button button--brand"
>
Done
</Button>
</div>
</Modal>
);
};
render() {
const {
handleSubmit,
onCancel,
onShowModal,
onShowApiTokenModal,
renderEmailModal,
renderPasswordModal,
renderApiTokenModal,
} = this;
const { version, errors, user, config, isPremiumTier } = this.props;
const { pendingEmail } = this.state;
if (!user) {
return false;
}
const {
global_role: globalRole,
updated_at: updatedAt,
sso_enabled: ssoEnabled,
teams,
} = user;
const roleText = generateRole(teams, globalRole);
const teamsText = generateTeam(teams, globalRole);
const lastUpdatedAt = formatDistanceToNow(new Date(updatedAt), {
addSuffix: true,
});
return (
<div className={baseClass}>
<div className={`${baseClass}__manage body-wrap`}>
<h1>My account</h1>
<UserSettingsForm
formData={user}
handleSubmit={handleSubmit}
onCancel={onCancel}
pendingEmail={pendingEmail}
serverErrors={errors}
smtpConfigured={config.configured}
/>
</div>
<div className={`${baseClass}__additional body-wrap`}>
<div className={`${baseClass}__change-avatar`}>
<Avatar user={user} className={`${baseClass}__avatar`} />
<a href="http://en.gravatar.com/emails/">
Change photo at Gravatar
</a>
</div>
{isPremiumTier && (
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Teams</p>
<p
className={`${baseClass}__description ${baseClass}__teams ${greyCell(
teamsText
)}`}
>
{teamsText}
</p>
</div>
)}
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Role</p>
<p
className={`${baseClass}__description ${baseClass}__role ${greyCell(
roleText
)}`}
>
{roleText}
</p>
</div>
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Password</p>
</div>
<Button
onClick={onShowModal}
disabled={ssoEnabled}
className={`${baseClass}__button`}
>
Change password
</Button>
<p className={`${baseClass}__last-updated`}>
Last changed: {lastUpdatedAt}
</p>
<Button
onClick={onShowApiTokenModal}
className={`${baseClass}__button`}
>
Get API token
</Button>
<span
className={`${baseClass}__version`}
>{`Fleet ${version.version} • Go ${version.go_version}`}</span>
<span className={`${baseClass}__privacy-policy`}>
<a
href="https://fleetdm.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy policy
</a>
</span>
</div>
{renderEmailModal()}
{renderPasswordModal()}
{renderApiTokenModal()}
</div>
);
}
}
const mapStateToProps = (state) => {
const { data: version } = state.version;
const { errors, user } = state.auth;
const { config } = state.app;
const { errors: userErrors } = state.entities.users;
const isPremiumTier = permissionUtils.isPremiumTier(config);
return { version, errors, user, userErrors, config, isPremiumTier };
};
export default connect(mapStateToProps)(UserSettingsPage);
@@ -1,124 +0,0 @@
import React from "react";
import { mount } from "enzyme";
import { noop } from "lodash";
import ConnectedPage, {
UserSettingsPage,
} from "pages/UserSettingsPage/UserSettingsPage";
import testHelpers from "test/helpers";
import { userStub, configStub, adminUserStub } from "test/stubs";
import * as authActions from "redux/nodes/auth/actions";
const { connectedComponent, fillInFormInput, reduxMockStore } = testHelpers;
jest.mock("date-fns");
describe("UserSettingsPage - component", () => {
const store = {
auth: { user: userStub },
app: { config: configStub },
entities: { users: {} },
version: { data: {} },
};
const mockStore = reduxMockStore(store);
it("renders a UserSettingsForm component", () => {
const Page = mount(connectedComponent(ConnectedPage, { mockStore }));
expect(Page.find("UserSettingsForm").length).toEqual(1);
});
it("contains expected text", () => {
const pageWithUser = mount(
<UserSettingsPage dispatch={noop} user={userStub} config={configStub} />
);
const pageWithAdmin = mount(
<UserSettingsPage
dispatch={noop}
user={adminUserStub}
config={configStub}
/>
);
expect(pageWithUser.find(".user-settings__role").text()).toContain(
"Observer"
);
expect(pageWithAdmin.find(".user-settings__role").text()).toContain(
"Admin"
);
});
it("updates a user with only the updated attributes", () => {
jest.spyOn(authActions, "updateUser");
const dispatch = () => Promise.resolve();
const props = { dispatch, user: userStub, config: configStub };
const pageNode = mount(<UserSettingsPage {...props} />).instance();
const updatedAttrs = { name: "Updated Name" };
const updatedUser = { ...userStub, ...updatedAttrs };
pageNode.handleSubmit(updatedUser);
expect(authActions.updateUser).toHaveBeenCalledWith(userStub, updatedAttrs);
});
describe("changing email address", () => {
it("renders the ChangeEmailForm when the user changes their email", () => {
const Page = mount(connectedComponent(ConnectedPage, { mockStore }));
const UserSettingsForm = Page.find("UserSettingsForm");
const emailInput = UserSettingsForm.find({ name: "email" });
expect(Page.find("ChangeEmailForm").length).toEqual(
0,
"Expected the ChangeEmailForm to not render"
);
fillInFormInput(emailInput, "new@email.org");
UserSettingsForm.simulate("submit");
expect(Page.find("ChangeEmailForm").length).toEqual(
1,
"Expected the ChangeEmailForm to render"
);
});
it("does not render the ChangeEmailForm when the user does not change their email", () => {
const Page = mount(connectedComponent(ConnectedPage, { mockStore }));
const UserSettingsForm = Page.find("UserSettingsForm");
const emailInput = UserSettingsForm.find({ name: "email" });
expect(Page.find("ChangeEmailForm").length).toEqual(
0,
"Expected the ChangeEmailForm to not render"
);
fillInFormInput(emailInput, userStub.email);
UserSettingsForm.simulate("submit");
expect(Page.find("ChangeEmailForm").length).toEqual(
0,
"Expected the ChangeEmailForm to not render"
);
});
it("displays pending email text when the user is pending an email change", () => {
const props = { dispatch: noop, user: userStub, config: configStub };
const Page = mount(<UserSettingsPage {...props} />);
const UserSettingsForm = () => Page.find("UserSettingsForm");
const emailHint = () =>
UserSettingsForm().find(".manage-user__email-hint");
expect(emailHint().length).toEqual(
0,
"Expected the form to not render an email hint"
);
Page.setState({ pendingEmail: "new@email.org" });
expect(emailHint().length).toEqual(
1,
"Expected the form to render an email hint"
);
});
});
});
@@ -0,0 +1,360 @@
import React, { useState, useContext, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { formatDistanceToNow } from "date-fns";
import { authToken } from "utilities/local"; // @ts-ignore
import { stringToClipboard } from "utilities/copy_text";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification"; // @ts-ignore
import { IVersionData } from "interfaces/version";
import { IUser } from "interfaces/user"; // @ts-ignore
import deepDifference from "utilities/deep_difference";
import usersAPI from "services/entities/users";
import versionAPI from "services/entities/version";
import formatErrorResponse from "utilities/format_error_response";
import { generateRole, generateTeam, greyCell } from "fleet/helpers";
import Avatar from "components/Avatar";
import Button from "components/buttons/Button"; // @ts-ignore
import ChangeEmailForm from "components/forms/ChangeEmailForm"; // @ts-ignore
import ChangePasswordForm from "components/forms/ChangePasswordForm"; // @ts-ignore
import FleetIcon from "components/icons/FleetIcon"; // @ts-ignore
import InputField from "components/forms/fields/InputField";
import Modal from "components/Modal"; // @ts-ignore
import UserSettingsForm from "components/forms/UserSettingsForm";
const baseClass = "user-settings";
interface IUserSettingsPageProps {
router: InjectedRouter;
}
const UserSettingsPage = ({ router }: IUserSettingsPageProps) => {
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [pendingEmail, setPendingEmail] = useState<string>("");
const [showEmailModal, setShowEmailModal] = useState<boolean>(false);
const [showPasswordModal, setShowPasswordModal] = useState<boolean>(false);
const [updatedUser, setUpdatedUser] = useState<Partial<IUser>>({});
const [copyMessage, setCopyMessage] = useState<string>("");
const [showApiTokenModal, setShowApiTokenModal] = useState<boolean>(false);
const [revealSecret, setRevealSecret] = useState<boolean>(false);
const [versionData, setVersionData] = useState<IVersionData>();
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [userErrors, setUserErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
const getVersionData = async () => {
try {
const data = await versionAPI.load();
setVersionData(data);
} catch (response) {
console.error(response);
return false;
}
};
getVersionData();
}, []);
const onCancel = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
return router.goBack();
};
const onShowPasswordModal = () => {
setShowPasswordModal(true);
return false;
};
const onShowApiTokenModal = () => {
setShowApiTokenModal(true);
return false;
};
const onToggleEmailModal = (updated = {}) => {
setShowEmailModal(!showEmailModal);
setUpdatedUser(updated);
return false;
};
const onTogglePasswordModal = () => {
setShowPasswordModal(!showPasswordModal);
return false;
};
const onToggleApiTokenModal = () => {
setShowApiTokenModal(!showApiTokenModal);
return false;
};
const onToggleSecret = () => {
setRevealSecret(!revealSecret);
return false;
};
// placeholder is needed even though it's not used
const onCopySecret = (placeholder: string) => {
return (evt: ClipboardEvent) => {
evt.preventDefault();
stringToClipboard(authToken())
.then(() => setCopyMessage("Copied!"))
.catch(() => setCopyMessage("Copy failed"));
// Clear message after 1 second
setTimeout(() => setCopyMessage(""), 1000);
return false;
};
};
const handleSubmit = async (formData: any) => {
if (!currentUser) {
return false;
}
const updated = deepDifference(formData, currentUser);
if (updated.email && !updated.password) {
return onToggleEmailModal(updated);
}
try {
await usersAPI.update(currentUser.id, updated);
let accountUpdatedFlashMessage = "Account updated";
if (updated.email) {
accountUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${updated.email}`;
setPendingEmail(updated.email);
}
renderFlash("success", accountUpdatedFlashMessage);
return true;
} catch (response) {
const errorObject = formatErrorResponse(response);
setErrors(errorObject);
if (errorObject.base.includes("already exists")) {
renderFlash("error", "A user with this email address already exists.");
} else {
renderFlash("error", "Could not edit user. Please try again.");
}
setShowEmailModal(false);
return false;
}
};
const handleSubmitPasswordForm = async (formData: any) => {
try {
await usersAPI.changePassword(formData);
renderFlash("success", "Password changed successfully");
setShowPasswordModal(false);
} catch (response) {
const errorObject = formatErrorResponse(response);
setUserErrors(errorObject);
return false;
}
};
const renderEmailModal = () => {
const emailSubmit = (formData: any) => {
handleSubmit(formData).then((r?: boolean) => {
return r ? onToggleEmailModal() : false;
});
};
if (!showEmailModal) {
return false;
}
return (
<Modal title="Confirm email update" onExit={onToggleEmailModal}>
<>
<div className={`${baseClass}__confirm-update`}>
To update your email you must confirm your password.
</div>
<ChangeEmailForm
formData={updatedUser}
handleSubmit={emailSubmit}
onCancel={onToggleEmailModal}
serverErrors={errors}
/>
</>
</Modal>
);
};
const renderPasswordModal = () => {
if (!showPasswordModal) {
return false;
}
return (
<Modal title="Change password" onExit={onTogglePasswordModal}>
<ChangePasswordForm
handleSubmit={handleSubmitPasswordForm}
onCancel={onTogglePasswordModal}
serverErrors={userErrors}
/>
</Modal>
);
};
const renderLabel = () => {
return (
<span className={`${baseClass}__name`}>
<span className="buttons">
{copyMessage && <span>{`${copyMessage} `}</span>}
<Button
variant="unstyled"
className={`${baseClass}__secret-copy-icon`}
onClick={onCopySecret(`.${baseClass}__secret-input`)}
>
<FleetIcon name="clipboard" />
</Button>
</span>
</span>
);
};
const renderApiTokenModal = () => {
if (!showApiTokenModal) {
return false;
}
return (
<Modal title="Get API token" onExit={onToggleApiTokenModal}>
<>
<p className={`${baseClass}__secret-label`}>
Your API token:
<a
href="#revealSecret"
onClick={onToggleSecret}
className={`${baseClass}__reveal-secret`}
>
{revealSecret ? "Hide" : "Reveal"} Token
</a>
</p>
<div className={`${baseClass}__secret-wrapper`}>
<InputField
disabled
inputWrapperClass={`${baseClass}__secret-input`}
name="osqueryd-secret"
type={revealSecret ? "text" : "password"}
value={authToken()}
label={renderLabel()}
/>
</div>
<div className={`${baseClass}__button-wrap`}>
<Button
onClick={onToggleApiTokenModal}
className="button button--brand"
>
Done
</Button>
</div>
</>
</Modal>
);
};
if (!currentUser) {
return false;
}
const {
global_role: globalRole,
updated_at: updatedAt,
sso_enabled: ssoEnabled,
teams,
} = currentUser;
const roleText = generateRole(teams, globalRole);
const teamsText = generateTeam(teams, globalRole);
const lastUpdatedAt =
updatedAt &&
formatDistanceToNow(new Date(updatedAt), {
addSuffix: true,
});
return (
<div className={baseClass}>
<div className={`${baseClass}__manage body-wrap`}>
<h1>My account</h1>
<UserSettingsForm
formData={currentUser}
handleSubmit={handleSubmit}
onCancel={onCancel}
pendingEmail={pendingEmail}
serverErrors={errors}
smtpConfigured={config?.smtp_settings.configured}
/>
</div>
<div className={`${baseClass}__additional body-wrap`}>
<div className={`${baseClass}__change-avatar`}>
<Avatar user={currentUser} className={`${baseClass}__avatar`} />
<a href="http://en.gravatar.com/emails/">Change photo at Gravatar</a>
</div>
{isPremiumTier && (
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Teams</p>
<p
className={`${baseClass}__description ${baseClass}__teams ${greyCell(
teamsText
)}`}
>
{teamsText}
</p>
</div>
)}
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Role</p>
<p
className={`${baseClass}__description ${baseClass}__role ${greyCell(
roleText
)}`}
>
{roleText}
</p>
</div>
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Password</p>
</div>
<Button
onClick={onShowPasswordModal}
disabled={ssoEnabled}
className={`${baseClass}__button`}
>
Change password
</Button>
<p className={`${baseClass}__last-updated`}>
Last changed: {lastUpdatedAt}
</p>
<Button
onClick={onShowApiTokenModal}
className={`${baseClass}__button`}
>
Get API token
</Button>
<span
className={`${baseClass}__version`}
>{`Fleet ${versionData?.version} • Go ${versionData?.go_version}`}</span>
<span className={`${baseClass}__privacy-policy`}>
<a
href="https://fleetdm.com/legal/privacy"
target="_blank"
rel="noopener noreferrer"
>
Privacy policy
</a>
</span>
</div>
{renderEmailModal()}
{renderPasswordModal()}
{renderApiTokenModal()}
</div>
);
};
export default UserSettingsPage;
@@ -1,15 +1,13 @@
import React, { useCallback, useContext } from "react";
import { useDispatch } from "react-redux";
import { useQuery } from "react-query";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification"; // @ts-ignore
import { getConfig } from "redux/nodes/app/actions";
import configAPI from "services/entities/config";
// @ts-ignore
import deepDifference from "utilities/deep_difference";
import { IConfig, IConfigNested } from "interfaces/config";
import { IConfig } from "interfaces/config";
import { IApiError } from "interfaces/errors";
// @ts-ignore
@@ -18,25 +16,22 @@ import AppConfigForm from "components/forms/admin/AppConfigForm";
export const baseClass = "app-settings";
const AppSettingsPage = (): JSX.Element => {
const dispatch = useDispatch();
const { renderFlash } = useContext(NotificationContext);
const { setConfig } = useContext(AppContext);
const {
data: appConfig,
isLoading: isLoadingConfig,
refetch: refetchConfig,
} = useQuery<IConfigNested, Error, IConfigNested>(
["config"],
() => configAPI.loadAll(),
{
select: (data: IConfigNested) => data,
}
);
} = useQuery<IConfig, Error, IConfig>(["config"], () => configAPI.loadAll(), {
select: (data: IConfig) => data,
onSuccess: (data) => {
setConfig(data);
},
});
const onFormSubmit = useCallback(
(formData: IConfigNested) => {
(formData: IConfig) => {
const diff = deepDifference(formData, appConfig);
// send all formData.agent_options because diff overrides all agent options
diff.agent_options = formData.agent_options;
@@ -63,15 +58,9 @@ const AppSettingsPage = (): JSX.Element => {
})
.finally(() => {
refetchConfig();
// Config must be updated in both Redux and AppContext
dispatch(getConfig())
.then((configState: IConfig) => {
setConfig(configState);
})
.catch(() => false);
});
},
[dispatch, appConfig, getConfig, setConfig]
[appConfig]
);
// WHY???
@@ -1,5 +1,4 @@
import React, { useContext, useState } from "react";
import { useDispatch } from "react-redux";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import yaml from "js-yaml";
@@ -7,8 +6,10 @@ import yaml from "js-yaml";
import { NotificationContext } from "context/notification";
import { ITeam } from "interfaces/team";
import endpoints from "fleet/endpoints";
import teamsAPI from "services/entities/teams"; // @ts-ignore
import osqueryOptionsActions from "redux/nodes/osquery/actions"; // @ts-ignore
import teamsAPI from "services/entities/teams";
import osqueryOptionsAPI from "services/entities/osquery_options";
// @ts-ignore
import validateYaml from "components/forms/validators/validate_yaml"; // @ts-ignore
import OsqueryOptionsForm from "components/forms/admin/OsqueryOptionsForm";
import InfoBanner from "components/InfoBanner/InfoBanner";
@@ -30,7 +31,6 @@ const AgentOptionsPage = ({
params: { team_id },
}: IAgentOptionsPageProps): JSX.Element => {
const teamIdFromURL = parseInt(team_id, 10);
const dispatch = useDispatch();
const { renderFlash } = useContext(NotificationContext);
const [formData, setFormData] = useState<{ osquery_options?: string }>({});
@@ -56,29 +56,25 @@ const AgentOptionsPage = ({
}
);
const onSaveOsqueryOptionsFormSubmit = (updatedForm: {
const onSaveOsqueryOptionsFormSubmit = async (updatedForm: {
osquery_options: string;
}): void | false => {
}) => {
const { TEAMS_AGENT_OPTIONS } = endpoints;
const { error } = validateYaml(updatedForm.osquery_options);
if (error) {
renderFlash("error", error.reason);
return false;
return renderFlash("error", error.reason);
}
dispatch(
osqueryOptionsActions.updateOsqueryOptions(
try {
await osqueryOptionsAPI.update(
updatedForm,
TEAMS_AGENT_OPTIONS(teamIdFromURL)
)
)
.then(() => {
renderFlash("success", "Successfully saved agent options");
})
.catch((errors: { [key: string]: string }) => {
renderFlash("error", errors.stack);
});
return false;
);
return renderFlash("success", "Successfully saved agent options");
} catch (response) {
console.error(response);
return renderFlash("error", "Could not save agent options");
}
};
return (
@@ -66,8 +66,8 @@ const MembersPage = ({
AppContext
);
const smtpConfigured = config?.configured || false;
const canUseSso = config?.enable_sso || false;
const smtpConfigured = config?.smtp_settings.configured || false;
const canUseSso = config?.sso_settings.enable_sso || false;
const [showAddMemberModal, setShowAddMemberModal] = useState<boolean>(false);
const [showRemoveMemberModal, setShowRemoveMemberModal] = useState<boolean>(
@@ -235,7 +235,7 @@ const MembersPage = ({
.then(() => {
renderFlash(
"success",
`An invitation email was sent from ${config?.sender_address} to ${formData.email}.`
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.`
);
fetchUsers(tableQueryData);
toggleCreateMemberModal();
@@ -1,5 +1,4 @@
import React, { useState, useCallback, useContext } from "react";
import { useDispatch } from "react-redux";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { InjectedRouter, Link, RouteProps } from "react-router";
@@ -14,14 +13,13 @@ import { ITeam, ITeamSummary } from "interfaces/team";
import teamsAPI from "services/entities/teams";
import usersAPI, { IGetMeResponse } from "services/entities/users";
import enrollSecretsAPI from "services/entities/enroll_secret";
import teamActions from "redux/nodes/entities/teams/actions";
import {
IEnrollSecret,
IEnrollSecretsResponse,
} from "interfaces/enroll_secret";
import { IOldApiError } from "interfaces/errors";
import permissions from "utilities/permissions";
import sortUtils from "utilities/sort";
import formatErrorResponse from "utilities/format_error_response";
import Spinner from "components/Spinner";
import Button from "components/buttons/Button";
@@ -100,7 +98,6 @@ const TeamDetailsWrapper = ({
location: { pathname },
params: routeParams,
}: ITeamDetailsPageProps): JSX.Element => {
const dispatch = useDispatch();
const { renderFlash } = useContext(NotificationContext);
const handlePageError = useErrorHandler();
const teamIdFromURL = parseInt(routeParams.team_id, 10) || 0;
@@ -281,48 +278,66 @@ const TeamDetailsWrapper = ({
}
};
const onDeleteSubmit = useCallback(() => {
dispatch(teamActions.destroy(currentTeam?.id))
.then(() => {
renderFlash("success", "Team removed");
router.push(PATHS.ADMIN_TEAMS);
// TODO: error handling
})
.catch(() => null);
const onDeleteSubmit = useCallback(async () => {
if (!currentTeam) {
return false;
}
toggleDeleteTeamModal();
try {
await teamsAPI.destroy(currentTeam.id);
renderFlash("success", "Team removed");
return router.push(PATHS.ADMIN_TEAMS);
} catch (response) {
renderFlash("error", "Something went wrong removing the team");
console.error(response);
return false;
}
}, [toggleDeleteTeamModal, currentTeam?.id]);
const onEditSubmit = useCallback(
(formData: IEditTeamFormData) => {
async (formData: IEditTeamFormData) => {
const updatedAttrs =
currentTeam && generateUpdateData(currentTeam, formData);
if (!currentTeam) {
return false;
}
// no updates, so no need for a request.
if (updatedAttrs === null) {
if (!updatedAttrs) {
toggleEditTeamModal();
return;
}
dispatch(teamActions.update(currentTeam?.id, updatedAttrs))
.then(() => {
dispatch(teamActions.loadAll({ perPage: 500 }));
renderFlash(
"success",
`Successfully updated team name to ${updatedAttrs?.name}`
);
setBackendValidators({});
refetchTeams();
refetchMe();
try {
await teamsAPI.update(currentTeam.id, updatedAttrs);
await teamsAPI.loadAll({ perPage: 500 });
renderFlash(
"success",
`Successfully updated team name to ${updatedAttrs?.name}`
);
setBackendValidators({});
refetchTeams();
refetchMe();
toggleEditTeamModal();
} catch (response) {
console.error(response);
const errorObject = formatErrorResponse(response);
if (errorObject.base.includes("Duplicate")) {
setBackendValidators({
name: "A team with this name already exists",
});
} else {
renderFlash("error", "Could not create team. Please try again.");
toggleEditTeamModal();
})
.catch((updateError: IOldApiError) => {
if (updateError.base.includes("Duplicate")) {
setBackendValidators({
name: "A team with this name already exists",
});
} else {
renderFlash("error", "Could not create team. Please try again.");
toggleEditTeamModal();
}
});
}
return false;
}
},
[toggleEditTeamModal, currentTeam, setBackendValidators]
);
@@ -1,5 +1,6 @@
import React, { useState, useCallback, useContext } from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { NotificationContext } from "context/notification";
import { ITeam } from "interfaces/team";
@@ -34,6 +35,7 @@ const TeamManagementPage = (): JSX.Element => {
const [backendValidators, setBackendValidators] = useState<{
[key: string]: string;
}>({});
const handlePageError = useErrorHandler();
const {
data: teams,
@@ -45,6 +47,7 @@ const TeamManagementPage = (): JSX.Element => {
() => teamsAPI.loadAll(),
{
select: (data: ITeamsResponse) => data.teams,
onError: (error) => handlePageError(error),
}
);
@@ -1,13 +1,14 @@
import React, { useState, useCallback, useContext } from "react";
import { useDispatch } from "react-redux";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import memoize from "memoize-one";
import paths from "router/paths";
import { IApiError } from "interfaces/errors";
import { IInvite } from "interfaces/invite";
import { IUser, IUserFormErrors } from "interfaces/user";
import { ITeam } from "interfaces/team";
import { clearToken } from "utilities/local";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
@@ -15,7 +16,6 @@ import teamsAPI from "services/entities/teams";
import usersAPI from "services/entities/users";
import invitesAPI from "services/entities/invites";
import paths from "router/paths";
import TableContainer, { ITableQueryData } from "components/TableContainer";
import TableDataError from "components/TableDataError";
import Modal from "components/Modal";
@@ -40,8 +40,6 @@ interface ITeamsResponse {
}
const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
const dispatch = useDispatch();
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
@@ -234,7 +232,7 @@ const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
.then(() => {
renderFlash(
"success",
`An invitation email was sent from ${config?.sender_address} to ${formData.email}.`
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.`
);
toggleCreateUserModal();
refetchInvites();
@@ -335,7 +333,7 @@ const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
let userUpdatedFlashMessage = `Successfully edited ${formData.name}`;
if (userData?.email !== formData.email) {
userUpdatedFlashMessage += `: A confirmation email was sent from ${config?.sender_address} to ${formData.email}`;
userUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}`;
}
return (
@@ -405,7 +403,10 @@ const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
.deleteSessions(userEditing.id)
.then(() => {
if (isResettingCurrentUser) {
dispatch({ type: "LOGOUT_SUCCESS" });
clearToken();
setTimeout(() => {
window.location.href = "/";
}, 500);
return;
}
renderFlash("success", "Successfully reset sessions.");
@@ -454,8 +455,8 @@ const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
onSubmit={onEditUser}
availableTeams={teams || []}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.configured || false}
canUseSso={config?.enable_sso || false}
smtpConfigured={config?.smtp_settings.configured || false}
canUseSso={config?.sso_settings.enable_sso || false}
isSsoEnabled={userData?.sso_enabled}
isModifiedByGlobalAdmin
isInvitePending={userEditing.type === "invite"}
@@ -476,8 +477,8 @@ const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
defaultGlobalRole={"observer"}
defaultTeams={[]}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.configured || false}
canUseSso={config?.enable_sso || false}
smtpConfigured={config?.smtp_settings.configured || false}
canUseSso={config?.sso_settings.enable_sso || false}
isFormSubmitting={isFormSubmitting}
isModifiedByGlobalAdmin
/>
@@ -1,98 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { noop } from "lodash";
import { resetErrors } from "redux/nodes/errors500/actions";
import { Link } from "react-router";
import PATHS from "router/paths";
import Button from "components/buttons/Button";
import fleetLogoText from "../../../../assets/images/fleet-logo-text-white.svg";
import backgroundImg from "../../../../assets/images/500.svg";
import githubLogo from "../../../../assets/images/github-mark-white-24x24@2x.png";
import slackLogo from "../../../../assets/images/logo-slack-24x24@2x.png";
const baseClass = "fleet-500";
class Fleet500 extends Component {
static propTypes = {
dispatch: PropTypes.func,
};
static defaultProps = {
dispatch: noop,
};
constructor(props) {
super(props);
this.state = {
showErrorMessage: false,
};
}
componentWillUnmount() {
const { dispatch } = this.props;
dispatch(resetErrors());
}
onShowErrorMessage = () => {
this.setState({ showErrorMessage: true });
};
render() {
return (
<div className={baseClass}>
<header className="primary-header">
<Link to={PATHS.HOME}>
<img
className="primary-header__logo"
src={fleetLogoText}
alt="Fleet logo"
/>
</Link>
</header>
<img
className="background-image"
src={backgroundImg}
alt="500 background"
/>
<main>
<h1>
<span>500:</span> Oh, something went wrong.
</h1>
<p>Please file an issue if you believe this is a bug.</p>
<div className={`${baseClass}__button-wrapper`}>
<a
href="https://osquery.slack.com/join/shared_invite/zt-h29zm0gk-s2DBtGUTW4CFel0f0IjTEw#/"
target="_blank"
rel="noopener noreferrer"
>
<Button
type="button"
variant="unstyled"
className={`${baseClass}__slack-btn`}
>
<img src={slackLogo} alt="Slack icon" />
Get help on Slack
</Button>
</a>
<a
href="https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md&title="
target="_blank"
rel="noopener noreferrer"
>
<Button type="button">
<img src={githubLogo} alt="Github icon" />
File an issue
</Button>
</a>
</div>
</main>
</div>
);
}
}
export default Fleet500;
@@ -0,0 +1,69 @@
import React from "react";
import { Link } from "react-router";
import PATHS from "router/paths";
import Button from "components/buttons/Button"; // @ts-ignore
import fleetLogoText from "../../../../assets/images/fleet-logo-text-white.svg"; // @ts-ignore
import backgroundImg from "../../../../assets/images/500.svg";
import githubLogo from "../../../../assets/images/github-mark-white-24x24@2x.png";
import slackLogo from "../../../../assets/images/logo-slack-24x24@2x.png";
const baseClass = "fleet-500";
const Fleet500 = () => (
<div className={baseClass}>
<header className="primary-header">
<Link to={PATHS.HOME}>
<img
className="primary-header__logo"
src={fleetLogoText}
alt="Fleet logo"
/>
</Link>
</header>
<img
className="background-image"
src={backgroundImg}
alt="500 background"
/>
<main>
<h1>
<span>500:</span> Oh, something went wrong.
</h1>
<p>Please file an issue if you believe this is a bug.</p>
<div className={`${baseClass}__button-wrapper`}>
<a
href="https://osquery.slack.com/join/shared_invite/zt-h29zm0gk-s2DBtGUTW4CFel0f0IjTEw#/"
target="_blank"
rel="noopener noreferrer"
>
<Button
type="button"
variant="unstyled"
className={`${baseClass}__slack-btn`}
>
<>
<img src={slackLogo} alt="Slack icon" />
Get help on Slack
</>
</Button>
</a>
<a
href="https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md&title="
target="_blank"
rel="noopener noreferrer"
>
<Button type="button">
<>
<img src={githubLogo} alt="Github icon" />
File an issue
</>
</Button>
</a>
</div>
</main>
</div>
);
export default Fleet500;
@@ -1,5 +1,4 @@
import React, { useContext, useState, useCallback, useEffect } from "react";
import { useDispatch } from "react-redux";
import { Link } from "react-router";
import { Params, InjectedRouter } from "react-router/lib/Router";
import { useQuery } from "react-query";
@@ -95,7 +94,6 @@ const HostDetailsPage = ({
params: { host_id },
}: IHostDetailsProps): JSX.Element => {
const hostIdFromURL = parseInt(host_id, 10);
const dispatch = useDispatch();
const {
isGlobalAdmin,
isPremiumTier,
@@ -465,9 +463,7 @@ const HostDetailsPage = ({
setShowTransferHostModal(false);
} catch (error) {
console.log(error);
dispatch(
renderFlash("error", "Could not transfer host. Please try again.")
);
renderFlash("error", "Could not transfer host. Please try again.");
}
};
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { ISoftware } from "interfaces/software";
import { VULNERABLE_DROPDOWN_OPTIONS } from "utilities/constants";
@@ -1,6 +1,5 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useDispatch } from "react-redux";
import { InjectedRouter } from "react-router/lib/Router";
import { noop } from "lodash";
@@ -11,8 +10,8 @@ import { NotificationContext } from "context/notification";
import { inMilliseconds, secondsToHms } from "fleet/helpers";
import { IPolicyStats, ILoadAllPoliciesResponse } from "interfaces/policy";
import { IWebhookFailingPolicies } from "interfaces/webhook";
import { IConfig, IConfigNested } from "interfaces/config"; // @ts-ignore
import { getConfig } from "redux/nodes/app/actions";
import { IConfig } from "interfaces/config";
import PATHS from "router/paths";
import configAPI from "services/entities/config";
import globalPoliciesAPI from "services/entities/global_policies";
@@ -51,8 +50,6 @@ const ManagePolicyPage = ({
router,
location,
}: IManagePoliciesPageProps): JSX.Element => {
const dispatch = useDispatch();
const {
availableTeams,
config,
@@ -93,6 +90,10 @@ const ManagePolicyPage = ({
const [showAddPolicyModal, setShowAddPolicyModal] = useState(false);
const [showRemovePoliciesModal, setShowRemovePoliciesModal] = useState(false);
const [showInheritedPolicies, setShowInheritedPolicies] = useState(false);
const [
failingPoliciesWebhook,
setFailingPoliciesWebhook,
] = useState<IWebhookFailingPolicies>();
const [currentAutomatedPolicies, setCurrentAutomatedPolicies] = useState<
number[]
>();
@@ -143,22 +144,19 @@ const ManagePolicyPage = ({
const canAddOrRemovePolicy =
isGlobalAdmin || isGlobalMaintainer || isTeamMaintainer || isTeamAdmin;
const {
data: failingPoliciesWebhook,
isLoading: isLoadingFailingPoliciesWebhook,
refetch: refetchFailingPoliciesWebhook,
} = useQuery<IConfigNested, Error, IWebhookFailingPolicies>(
["config"],
() => configAPI.loadAll(),
{
enabled: canAddOrRemovePolicy,
select: (data: IConfigNested) =>
data.webhook_settings.failing_policies_webhook,
onSuccess: (data) => {
setCurrentAutomatedPolicies(data.policy_ids);
},
}
);
const { isLoading: isLoadingConfig, refetch: refetchConfig } = useQuery<
IConfig,
Error
>(["config"], () => configAPI.loadAll(), {
enabled: canAddOrRemovePolicy,
onSuccess: (data) => {
setFailingPoliciesWebhook(data.webhook_settings.failing_policies_webhook);
setCurrentAutomatedPolicies(
data.webhook_settings.failing_policies_webhook.policy_ids
);
setConfig(data);
},
});
const refetchPolicies = (id?: number) => {
refetchGlobalPolicies();
@@ -230,13 +228,7 @@ const ManagePolicyPage = ({
);
} finally {
toggleManageAutomationsModal();
refetchFailingPoliciesWebhook();
// Config must be updated in both Redux and AppContext
dispatch(getConfig())
.then((configState: IConfig) => {
setConfig(configState);
})
.catch(() => false);
refetchConfig();
}
};
@@ -291,8 +283,9 @@ const ManagePolicyPage = ({
};
const policyUpdateInterval =
secondsToHms(inMilliseconds(config?.osquery_policy || 0) / 1000) ||
"osquery policy update interval";
secondsToHms(
inMilliseconds(config?.update_interval.osquery_policy || 0) / 1000
) || "osquery policy update interval";
const showTeamDescription = isPremiumTier && !!teamId;
@@ -366,7 +359,7 @@ const ManagePolicyPage = ({
<div className={`${baseClass} button-wrap`}>
{canAddOrRemovePolicy &&
teamId === 0 &&
!isLoadingFailingPoliciesWebhook &&
!isLoadingConfig &&
!isLoadingGlobalPolicies && (
<Button
onClick={() => onManageAutomationsClick()}
@@ -423,14 +416,12 @@ const ManagePolicyPage = ({
{!!teamId && teamPoliciesError && <TableDataError />}
{!!teamId &&
!teamPoliciesError &&
(isLoadingTeamPolicies && isLoadingFailingPoliciesWebhook ? (
(isLoadingTeamPolicies && isLoadingConfig ? (
<Spinner />
) : (
<PoliciesListWrapper
policiesList={teamPolicies || []}
isLoading={
isLoadingTeamPolicies && isLoadingFailingPoliciesWebhook
}
isLoading={isLoadingTeamPolicies && isLoadingConfig}
onRemovePoliciesClick={onRemovePoliciesClick}
canAddOrRemovePolicy={canAddOrRemovePolicy}
currentTeam={currentTeam}
@@ -445,9 +436,7 @@ const ManagePolicyPage = ({
) : (
<PoliciesListWrapper
policiesList={globalPolicies || []}
isLoading={
isLoadingGlobalPolicies && isLoadingFailingPoliciesWebhook
}
isLoading={isLoadingGlobalPolicies && isLoadingConfig}
onRemovePoliciesClick={onRemovePoliciesClick}
canAddOrRemovePolicy={canAddOrRemovePolicy}
currentTeam={currentTeam}
@@ -482,9 +471,7 @@ const ManagePolicyPage = ({
<Spinner />
) : (
<PoliciesListWrapper
isLoading={
isLoadingGlobalPolicies && isLoadingFailingPoliciesWebhook
}
isLoading={isLoadingGlobalPolicies && isLoadingConfig}
policiesList={globalPolicies || []}
onRemovePoliciesClick={noop}
resultsTitle="policies"
@@ -3,7 +3,7 @@
import React, { useState, useContext, useEffect, KeyboardEvent } from "react";
import { IAceEditor } from "react-ace/lib/types";
import ReactTooltip from "react-tooltip";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { size } from "lodash";
import classnames from "classnames";
@@ -4,8 +4,8 @@ import SockJS from "sockjs-client";
import { PolicyContext } from "context/policy";
import { NotificationContext } from "context/notification";
import { formatSelectedTargetsForApi } from "fleet/helpers";
// @ts-ignore
import campaignHelpers from "redux/nodes/entities/campaigns/helpers";
import campaignHelpers from "utilities/campaign_helpers";
import queryAPI from "services/entities/queries"; // @ts-ignore
import debounce from "utilities/debounce"; // @ts-ignore
import { BASE_URL, DEFAULT_CAMPAIGN_STATE } from "utilities/constants"; // @ts-ignore
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import { Row } from "react-table";
import { forEach, isEmpty, remove, unionBy } from "lodash";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { formatSelectedTargetsForApi } from "fleet/helpers";
import useQueryTargets, { ITargetsQueryResponse } from "hooks/useQueryTargets";
@@ -2,7 +2,7 @@ import React, { useState, useContext, useEffect, KeyboardEvent } from "react";
import { InjectedRouter } from "react-router";
import { size } from "lodash";
import classnames from "classnames";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import PATHS from "router/paths";
import { AppContext } from "context/app";
@@ -3,9 +3,10 @@ import SockJS from "sockjs-client";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import { formatSelectedTargetsForApi } from "fleet/helpers"; // @ts-ignore
import campaignHelpers from "redux/nodes/entities/campaigns/helpers";
import queryAPI from "services/entities/queries"; // @ts-ignore
import { formatSelectedTargetsForApi } from "fleet/helpers";
import queryAPI from "services/entities/queries";
import campaignHelpers from "utilities/campaign_helpers"; // @ts-ignore
import debounce from "utilities/debounce"; // @ts-ignore
import { BASE_URL, DEFAULT_CAMPAIGN_STATE } from "utilities/constants"; // @ts-ignore
import local from "utilities/local"; // @ts-ignore
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import { Row } from "react-table";
import { forEach, isEmpty, remove, unionWith } from "lodash";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { formatSelectedTargetsForApi } from "fleet/helpers";
import useQueryTargets, { ITargetsQueryResponse } from "hooks/useQueryTargets";
@@ -10,10 +10,8 @@ import { IEditScheduledQuery } from "interfaces/scheduled_query";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import InfoBanner from "components/InfoBanner/InfoBanner";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
// @ts-ignore
import InfoBanner from "components/InfoBanner/InfoBanner"; // @ts-ignore
import Dropdown from "components/forms/fields/Dropdown"; // @ts-ignore
import InputField from "components/forms/fields/InputField";
import {
FREQUENCY_DROPDOWN_OPTIONS,
@@ -95,7 +93,7 @@ const ScheduleEditorModal = ({
}: IScheduleEditorModalProps): JSX.Element => {
const { config } = useContext(AppContext);
const loggingConfig = config?.result.plugin || "unknown";
const loggingConfig = config?.logging.result.plugin || "unknown";
const [showAdvancedOptions, setShowAdvancedOptions] = useState<boolean>(
false
@@ -1,15 +1,12 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useDispatch } from "react-redux";
import { InjectedRouter } from "react-router/lib/Router";
import { useDebouncedCallback } from "use-debounce/lib";
import { useDebouncedCallback } from "use-debounce";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { IConfig, IConfigNested } from "interfaces/config";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
// @ts-ignore
import { getConfig } from "redux/nodes/app/actions";
import { IConfig } from "interfaces/config";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook"; // @ts-ignore
import configAPI from "services/entities/config";
import softwareAPI, {
ISoftwareResponse,
@@ -58,7 +55,6 @@ const ManageSoftwarePage = ({
router,
location,
}: IManageSoftwarePageProps): JSX.Element => {
const dispatch = useDispatch();
const {
availableTeams,
currentTeam,
@@ -69,6 +65,10 @@ const ManageSoftwarePage = ({
const { renderFlash } = useContext(NotificationContext);
const [isSoftwareEnabled, setIsSoftwareEnabled] = useState<boolean>();
const [
softwareVulnerabilitiesWebhook,
setSoftwareVulnerabilitiesWebhook,
] = useState<IWebhookSoftwareVulnerabilities>();
const [filterVuln, setFilterVuln] = useState(
location?.query?.vulnerable || false
);
@@ -168,19 +168,18 @@ const ManageSoftwarePage = ({
const canAddOrRemoveSoftwareWebhook = isGlobalAdmin || isGlobalMaintainer;
const {
data: softwareVulnerabilitiesWebhook,
isLoading: isLoadingSoftwareVulnerabilitiesWebhook,
refetch: refetchSoftwareVulnerabilitiesWebhook,
} = useQuery<IConfigNested, Error, IWebhookSoftwareVulnerabilities>(
["config"],
() => configAPI.loadAll(),
{
enabled: canAddOrRemoveSoftwareWebhook,
select: (data: IConfigNested) =>
data.webhook_settings.vulnerabilities_webhook,
}
);
const { isLoading: isLoadingConfig, refetch: refetchConfig } = useQuery<
IConfig,
Error
>(["config"], () => configAPI.loadAll(), {
enabled: canAddOrRemoveSoftwareWebhook,
onSuccess: (data) => {
setSoftwareVulnerabilitiesWebhook(
data.webhook_settings.vulnerabilities_webhook
);
setConfig(data);
},
});
const onQueryChange = useDebouncedCallback(
async ({
@@ -239,13 +238,7 @@ const ManageSoftwarePage = ({
);
} finally {
toggleManageAutomationsModal();
refetchSoftwareVulnerabilitiesWebhook();
// Config must be updated in both Redux and AppContext
dispatch(getConfig())
.then((configState: IConfig) => {
setConfig(configState);
})
.catch(() => false);
refetchConfig();
}
};
@@ -304,12 +297,12 @@ const ManageSoftwarePage = ({
buttons={(state) =>
renderHeaderButtons({
...state,
isLoading: isLoadingSoftwareVulnerabilitiesWebhook,
isLoading: isLoadingConfig,
})
}
/>
);
}, [router, location, isLoadingSoftwareVulnerabilitiesWebhook]);
}, [router, location, isLoadingConfig]);
const renderSoftwareCount = useCallback(() => {
const count = softwareCount;
@@ -1,3 +0,0 @@
import config from "./config";
export default config.actions;
@@ -1,19 +0,0 @@
import {
destroyFunc,
updateCampaignState,
} from "redux/nodes/entities/campaigns/helpers";
import Fleet from "fleet";
import Config from "redux/nodes/entities/base/config";
import schemas from "redux/nodes/entities/base/schemas";
const { CAMPAIGNS: schema } = schemas;
export default new Config({
createFunc: Fleet.queries.run,
destroyFunc,
updateFunc: updateCampaignState,
entityName: "campaigns",
schema,
});
export const initialState = Object.assign({}, Config.initialState);
@@ -1,143 +0,0 @@
import helpers from "./helpers";
const host = {
hostname: "jmeller-mbp.local",
id: 1,
};
const campaign = {
id: 4,
query_id: 12,
status: 0,
user_id: 1,
hosts_count: {
successful: 0,
failed: 0,
total: 0,
},
};
const campaignWithResults = {
...campaign,
hosts: [{ id: 2, hostname: "some-machine" }],
query_results: [
{ host: "some-machine", feature: "vendor", value: "GenuineIntel" },
],
totals: {
count: 3,
online: 2,
},
};
const { destroyFunc, updateCampaignState } = helpers;
const resultSocketData = {
type: "result",
data: {
distributed_query_execution_id: 5,
host,
rows: [
{ feature: "product_name", value: "Intel Core" },
{ feature: "family", value: "0600" },
],
},
};
const statusSocketData = {
type: "status",
data: "finished",
};
const totalsSocketData = {
type: "totals",
data: {
count: 5,
online: 1,
},
};
describe("campaign entity - helpers", () => {
describe("#destroyFunc", () => {
it("returns the campaign", (done) => {
destroyFunc(campaign)
.then((response) => {
expect(response).toEqual(campaign);
done();
})
.catch(done);
});
});
describe("#updateCampaignState", () => {
it("appends query results to the campaign when the campaign has query results", () => {
const state = { campaign: campaignWithResults };
const updatedState = updateCampaignState(resultSocketData)(state, {});
expect(updatedState.campaign.query_results).toEqual([
...campaignWithResults.query_results,
{ feature: "product_name", value: "Intel Core" },
{ feature: "family", value: "0600" },
]);
expect(updatedState.campaign.hosts).toContainEqual(host);
});
it("adds query results to the campaign when the campaign does not have query results", () => {
const state = { campaign };
const updatedState = updateCampaignState(resultSocketData)(state, {});
expect(updatedState.campaign.query_results).toEqual([
{ feature: "product_name", value: "Intel Core" },
{ feature: "family", value: "0600" },
]);
expect(updatedState.campaign.hosts).toContainEqual(host);
});
it("updates totals on the campaign when the campaign has totals", () => {
const state = { campaign: campaignWithResults };
const updatedState = updateCampaignState(totalsSocketData)(state, {});
expect(updatedState.campaign.totals).toEqual(totalsSocketData.data);
});
it("adds totals to the campaign when the campaign does not have totals", () => {
const state = { campaign };
const updatedState = updateCampaignState(totalsSocketData)(state, {});
expect(updatedState.campaign.totals).toEqual(totalsSocketData.data);
});
it("increases the successful hosts count and total when the result has no error", () => {
const state = { campaign };
const updatedState = updateCampaignState(resultSocketData)(state, {});
expect(updatedState.campaign.hosts_count).toEqual({
successful: 1,
failed: 0,
total: 1,
});
});
it("increases the failed hosts count and total when the result has an error", () => {
const resultErrorSocketData = {
type: "result",
data: {
...resultSocketData.data,
error: "failed",
},
};
const state = { campaign };
const updatedState = updateCampaignState(resultErrorSocketData)(
state,
{}
);
expect(updatedState.campaign.hosts_count).toEqual({
successful: 0,
failed: 1,
total: 1,
});
});
it("sets the queryIsRunning attribute for status socket data", () => {
const state = { campaign };
const updatedState = updateCampaignState(statusSocketData)(state, {});
expect(updatedState.queryIsRunning).toEqual(false);
});
});
});
@@ -1,3 +0,0 @@
import config from "./config";
export default config.reducer;
-2
View File
@@ -1,6 +1,5 @@
import { combineReducers } from "redux";
import campaigns from "./campaigns/reducer";
import hosts from "./hosts/reducer";
import invites from "./invites/reducer";
import labels from "./labels/reducer";
@@ -13,7 +12,6 @@ import users from "./users/reducer";
import teams from "./teams/reducer";
export default combineReducers({
campaigns,
hosts,
invites,
labels,
+95 -98
View File
@@ -5,6 +5,7 @@ import {
browserHistory,
IndexRedirect,
IndexRoute,
InjectedRouter,
Route,
Router,
} from "react-router";
@@ -16,22 +17,21 @@ import AdminUserManagementPage from "pages/admin/UserManagementPage";
import AdminTeamManagementPage from "pages/admin/TeamManagementPage";
import TeamDetailsWrapper from "pages/admin/TeamManagementPage/TeamDetailsWrapper";
import App from "components/App";
import AccessRoutes from "components/AccessRoutes";
import AuthenticatedAdminRoutes from "components/AuthenticatedAdminRoutes";
import AuthAnyAdminRoutes from "components/AuthAnyAdminRoutes";
import AuthenticatedRoutes from "components/AuthenticatedRoutes";
import AuthGlobalAdminMaintainerRoutes from "components/AuthGlobalAdminMaintainerRoutes";
import AuthAnyMaintainerAnyAdminRoutes from "components/AuthAnyMaintainerAnyAdminRoutes";
import PremiumTierRoutes from "components/PremiumTierRoutes";
import ConfirmInvitePage from "pages/ConfirmInvitePage";
import ConfirmSSOInvitePage from "pages/ConfirmSSOInvitePage";
import CoreLayout from "layouts/CoreLayout";
import DeviceUserPage from "pages/hosts/details/DeviceUserPage";
import EditPackPage from "pages/packs/EditPackPage";
import EmailTokenRedirect from "components/EmailTokenRedirect";
import ForgotPasswordPage from "pages/ForgotPasswordPage";
import HostDetailsPage from "pages/hosts/details/HostDetailsPage";
import Homepage from "pages/Homepage";
import LoginRoutes from "components/LoginRoutes";
import LoginPage, { LoginPreviewPage } from "pages/LoginPage";
import LogoutPage from "pages/LogoutPage";
import ManageHostsPage from "pages/hosts/ManageHostsPage";
import ManageSoftwarePage from "pages/software/ManageSoftwarePage";
@@ -45,6 +45,7 @@ import PoliciesPageWrapper from "components/policies/PoliciesPageWrapper";
import PolicyPage from "pages/policies/PolicyPage";
import QueryPage from "pages/queries/QueryPage";
import RegistrationPage from "pages/RegistrationPage";
import ResetPasswordPage from "pages/ResetPasswordPage";
import SchedulePageWrapper from "components/schedule/SchedulePageWrapper";
import SoftwarePageWrapper from "components/software/SoftwarePageWrapper";
import ApiOnlyUser from "pages/ApiOnlyUser";
@@ -57,17 +58,21 @@ import AgentOptionsPage from "pages/admin/TeamManagementPage/TeamDetailsWrapper/
import PATHS from "router/paths";
import store from "redux/store";
import AppProvider from "context/app";
import RoutingProvider from "context/routing";
interface IAppWrapperProps {
children: JSX.Element;
router: InjectedRouter;
}
const history = syncHistoryWithStore(browserHistory, store);
// App.tsx needs the context for user and config
const AppWrapper = ({ children }: IAppWrapperProps) => (
const AppWrapper = ({ children, router }: IAppWrapperProps) => (
<AppProvider>
<App>{children}</App>
<RoutingProvider>
<App router={router}>{children}</App>
</RoutingProvider>
</AppProvider>
);
@@ -76,106 +81,98 @@ const routes = (
<Router history={history}>
<Route path={PATHS.ROOT} component={AppWrapper}>
<Route path="setup" component={RegistrationPage} />
<Route path="previewlogin" component={LoginRoutes} />
<Route path="login" component={LoginRoutes}>
<Route path="invites/:invite_token" component={ConfirmInvitePage} />
<Route
path="ssoinvites/:invite_token"
component={ConfirmSSOInvitePage}
/>
<Route path="forgot" />
<Route path="reset" />
</Route>
<Route path="previewlogin" component={LoginPreviewPage} />
<Route path="login" component={LoginPage} />
<Route
path="login/invites/:invite_token"
component={ConfirmInvitePage}
/>
<Route
path="login/ssoinvites/:invite_token"
component={ConfirmSSOInvitePage}
/>
<Route path="login/forgot" component={ForgotPasswordPage} />
<Route path="login/reset" component={ResetPasswordPage} />
<Route component={AuthenticatedRoutes}>
<Route path="email/change/:token" component={EmailTokenRedirect} />
<Route path="logout" component={LogoutPage} />
<Route component={AccessRoutes}>
<Route component={CoreLayout}>
<IndexRedirect to={"dashboard"} />
<Route path="dashboard" component={Homepage} />
<Route path="settings" component={AuthAnyAdminRoutes}>
<IndexRedirect to={"/dashboard"} />
<Route component={SettingsWrapper}>
<Route component={AuthenticatedAdminRoutes}>
<Route
path="organization"
component={AdminAppSettingsPage}
/>
<Route path="users" component={AdminUserManagementPage} />
<Route component={PremiumTierRoutes}>
<Route path="teams" component={AdminTeamManagementPage} />
</Route>
</Route>
</Route>
<Route path="teams/:team_id" component={TeamDetailsWrapper}>
<Route path="members" component={MembersPage} />
<Route path="options" component={AgentOptionsPage} />
<Route component={CoreLayout}>
<IndexRedirect to={"dashboard"} />
<Route path="dashboard" component={Homepage} />
<Route path="settings" component={AuthAnyAdminRoutes}>
<IndexRedirect to={"/dashboard"} />
<Route component={SettingsWrapper}>
<Route component={AuthenticatedAdminRoutes}>
<Route path="organization" component={AdminAppSettingsPage} />
<Route path="users" component={AdminUserManagementPage} />
<Route path="teams" component={AdminTeamManagementPage} />
</Route>
</Route>
<Route path="hosts">
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageHostsPage} />
<Route
path="manage/labels/:label_id"
component={ManageHostsPage}
/>
<Route
path="manage/:active_label"
component={ManageHostsPage}
/>
<Route
path="manage/labels/:label_id/:active_label"
component={ManageHostsPage}
/>
<Route
path="manage/:active_label/labels/:label_id"
component={ManageHostsPage}
/>
<Route path=":host_id" component={HostDetailsPage} />
<Route path="teams/:team_id" component={TeamDetailsWrapper}>
<Route path="members" component={MembersPage} />
<Route path="options" component={AgentOptionsPage} />
</Route>
<Route path="software" component={SoftwarePageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageSoftwarePage} />
</Route>
<Route component={AuthGlobalAdminMaintainerRoutes}>
<Route path="packs" component={PackPageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManagePacksPage} />
<Route path="new" component={PackComposerPage} />
<Route path=":id">
<IndexRoute component={EditPackPage} />
<Route path="edit" component={EditPackPage} />
</Route>
</Route>
</Route>
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="schedule" component={SchedulePageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageSchedulePage} />
<Route
path="manage/teams/:team_id"
component={ManageSchedulePage}
/>
</Route>
</Route>
<Route path="queries">
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageQueriesPage} />
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="new" component={QueryPage} />
</Route>
<Route path=":id" component={QueryPage} />
</Route>
<Route path="policies" component={PoliciesPageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManagePoliciesPage} />
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="new" component={PolicyPage} />
</Route>
<Route path=":id" component={PolicyPage} />
</Route>
<Route path="profile" component={UserSettingsPage} />
</Route>
<Route path="hosts">
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageHostsPage} />
<Route
path="manage/labels/:label_id"
component={ManageHostsPage}
/>
<Route path="manage/:active_label" component={ManageHostsPage} />
<Route
path="manage/labels/:label_id/:active_label"
component={ManageHostsPage}
/>
<Route
path="manage/:active_label/labels/:label_id"
component={ManageHostsPage}
/>
<Route path=":host_id" component={HostDetailsPage} />
</Route>
<Route path="software" component={SoftwarePageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageSoftwarePage} />
</Route>
<Route component={AuthGlobalAdminMaintainerRoutes}>
<Route path="packs" component={PackPageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManagePacksPage} />
<Route path="new" component={PackComposerPage} />
<Route path=":id">
<IndexRoute component={EditPackPage} />
<Route path="edit" component={EditPackPage} />
</Route>
</Route>
</Route>
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="schedule" component={SchedulePageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageSchedulePage} />
<Route
path="manage/teams/:team_id"
component={ManageSchedulePage}
/>
</Route>
</Route>
<Route path="queries">
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManageQueriesPage} />
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="new" component={QueryPage} />
</Route>
<Route path=":id" component={QueryPage} />
</Route>
<Route path="policies" component={PoliciesPageWrapper}>
<IndexRedirect to={"manage"} />
<Route path="manage" component={ManagePoliciesPage} />
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="new" component={PolicyPage} />
</Route>
<Route path=":id" component={PolicyPage} />
</Route>
<Route path="profile" component={UserSettingsPage} />
</Route>
</Route>
<Route path="/device/:device_auth_token" component={DeviceUserPage} />
-1
View File
@@ -27,7 +27,6 @@ export default {
FORGOT_PASSWORD: `${URL_PREFIX}/login/forgot`,
API_ONLY_USER: `${URL_PREFIX}/apionlyuser`,
FLEET_403: `${URL_PREFIX}/403`,
// FLEET_500: `${URL_PREFIX}/500`,
LOGIN: `${URL_PREFIX}/login`,
LOGOUT: `${URL_PREFIX}/logout`,
MANAGE_HOSTS: `${URL_PREFIX}/hosts/manage`,
+7 -4
View File
@@ -2,12 +2,10 @@
import sendRequest from "services";
import endpoints from "fleet/endpoints";
import { IConfigNested } from "interfaces/config";
// TODO: add other methods from "fleet/entities/config"
import { IConfig } from "interfaces/config";
export default {
loadAll: (): Promise<IConfigNested> => {
loadAll: (): Promise<IConfig> => {
const { CONFIG } = endpoints;
const path = `${CONFIG}`;
@@ -31,6 +29,11 @@ export default {
return Promise.resolve(decodedCertificate);
});
},
loadEnrollSecret: () => {
const { GLOBAL_ENROLL_SECRETS } = endpoints;
return sendRequest("GET", GLOBAL_ENROLL_SECRETS);
},
update: (formData: any) => {
const { CONFIG } = endpoints;
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import endpoints from "fleet/endpoints";
import yaml from "js-yaml";
export default {
// Unneeded for teams, but might need this for global
loadAll: () => {
const { OSQUERY_OPTIONS } = endpoints;
return sendRequest("GET", OSQUERY_OPTIONS);
},
update: (osqueryOptionsData: any, endpoint: string) => {
const yamlOptions = yaml.load(osqueryOptionsData.osquery_options);
return sendRequest("POST", endpoint, yamlOptions);
},
};
+44 -1
View File
@@ -27,6 +27,11 @@ interface IForgotPassword {
email: string;
}
interface IUpdatePassword {
new_password: string;
old_password: string;
}
interface IRequirePasswordReset {
require: boolean;
}
@@ -37,6 +42,11 @@ export interface IGetMeResponse {
}
export default {
changePassword: (passwordParams: IUpdatePassword) => {
const { CHANGE_PASSWORD } = endpoints;
return sendRequest("POST", CHANGE_PASSWORD, passwordParams);
},
confirmEmailChange: (currentUser: IUser, token: string) => {
const { CONFIRM_EMAIL_CHANGE } = endpoints;
@@ -70,12 +80,18 @@ export default {
return sendRequest("DELETE", path);
},
enable: (user: IUser, enabled: boolean) => {
const { ENABLE_USER } = endpoints;
return sendRequest("POST", ENABLE_USER(user.id), {
enabled,
}).then((response) => helpers.addGravatarUrlToResource(response.user));
},
forgotPassword: ({ email }: IForgotPassword) => {
const { FORGOT_PASSWORD } = endpoints;
return sendRequest("POST", FORGOT_PASSWORD, { email });
},
// TODO: changePassword (UserSettingsPage.jsx refactor)
loadAll: ({
page = 0,
perPage = 100,
@@ -126,6 +142,13 @@ export default {
};
});
},
performRequiredPasswordReset: (new_password: string) => {
const { PERFORM_REQUIRED_PASSWORD_RESET } = endpoints;
return sendRequest("POST", PERFORM_REQUIRED_PASSWORD_RESET, {
new_password,
}).then((response) => helpers.addGravatarUrlToResource(response.user));
},
requirePasswordReset: (
userId: number,
{ require }: IRequirePasswordReset
@@ -137,6 +160,17 @@ export default {
helpers.addGravatarUrlToResource(response.user)
);
},
resetPassword: (formData: any) => {
const { RESET_PASSWORD } = endpoints;
return sendRequest("POST", RESET_PASSWORD, formData);
},
setup: (formData: any) => {
const { SETUP } = endpoints;
const setupData = helpers.setupData(formData);
return sendRequest("POST", SETUP, setupData);
},
update: (userId: number, formData: IUpdateUserFormData) => {
const { USERS } = endpoints;
const path = `${USERS}/${userId}`;
@@ -145,4 +179,13 @@ export default {
helpers.addGravatarUrlToResource(response.user)
);
},
updateAdmin: (user: IUser, admin: boolean) => {
const { UPDATE_USER_ADMIN } = endpoints;
return sendRequest(
"POST",
UPDATE_USER_ADMIN(user.id),
admin
).then((response) => helpers.addGravatarUrlToResource(response.user));
},
};
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import endpoints from "fleet/endpoints";
export default {
load: () => {
const { VERSION } = endpoints;
return sendRequest("GET", VERSION);
},
};
@@ -1,14 +1,10 @@
export const destroyFunc = (campaign) => {
return Promise.resolve(campaign);
};
const updateCampaignStateFromTotals = (campaign, { data }) => {
const updateCampaignStateFromTotals = (campaign: any, { data }: any) => {
return {
campaign: { ...campaign, totals: data },
};
};
const updateCampaignStateFromResults = (campaign, { data }) => {
const updateCampaignStateFromResults = (campaign: any, { data }: any) => {
const queryResults = campaign.query_results || [];
const errors = campaign.errors || [];
const hosts = campaign.hosts || [];
@@ -82,7 +78,7 @@ const updateCampaignStateFromResults = (campaign, { data }) => {
};
};
const updateCampaignStateFromStatus = (campaign, { data }) => {
const updateCampaignStateFromStatus = (campaign: any, { data }: any) => {
const { status } = data;
const updatedCampaign = { ...campaign, status };
@@ -92,8 +88,8 @@ const updateCampaignStateFromStatus = (campaign, { data }) => {
};
};
export const updateCampaignState = (socketData) => {
return (prevState) => {
export const updateCampaignState = (socketData: any) => {
return (prevState: any) => {
const { campaign } = prevState;
switch (socketData.type) {
@@ -109,4 +105,4 @@ export const updateCampaignState = (socketData) => {
};
};
export default { destroyFunc, updateCampaignState };
export default { updateCampaignState };
@@ -0,0 +1,36 @@
import { get, join } from "lodash";
import { IError } from "interfaces/errors";
const formatServerErrors = (errors: IError[]) => {
if (!errors || !errors.length) {
return {};
}
const result: { [key: string]: string } = {};
errors.forEach((error) => {
const { name, reason } = error;
if (result[name]) {
result[name] = join([result[name], reason], ", ");
} else {
result[name] = reason;
}
});
return result;
};
const formatErrorResponse = (errorResponse: any) => {
const errors =
get(errorResponse, "message.errors") ||
get(errorResponse, "data.errors") ||
[];
return {
...formatServerErrors(errors),
http_status: errorResponse.status,
} as any;
};
export default formatErrorResponse;
@@ -2,11 +2,11 @@ import { IUser } from "interfaces/user";
import { IConfig } from "interfaces/config";
export const isFreeTier = (config: IConfig): boolean => {
return config.tier === "free";
return config.license.tier === "free";
};
export const isPremiumTier = (config: IConfig): boolean => {
return config.tier === "premium";
return config.license.tier === "premium";
};
export const isGlobalAdmin = (user: IUser): boolean => {