Add SandboxGate to fleet UI that gates functionality when in sandbox mode (#6738)

This commit is contained in:
Gabriel Hernandez 2022-07-19 19:55:47 +01:00 committed by GitHub
parent 7afef3f035
commit f4b20b6ae5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1344 additions and 1317 deletions

View File

@ -0,0 +1 @@
- Add gating of features on org settings, users mangament, user settings, and manage hosts pages

View File

@ -698,7 +698,7 @@ describe(
});
it("hides access to Fleet Desktop settings", () => {
cy.visit("settings/organization");
cy.getAttached(".app-settings__form-nav-list").within(() => {
cy.getAttached(".org-settings-form__form-nav-list").within(() => {
cy.findByText(/organization info/i).should("exist");
cy.findByText(/fleet desktop/i).should("not.exist");
});

View File

@ -684,7 +684,7 @@ describe("Premium tier - Global Admin user", () => {
});
it("allows access to Fleet Desktop settings", () => {
cy.visit("settings/organization");
cy.getAttached(".app-settings__form-nav-list").within(() => {
cy.getAttached(".org-settings-form__form-nav-list").within(() => {
cy.findByText(/organization info/i).should("exist");
cy.findByText(/fleet desktop/i)
.should("exist")

View File

@ -0,0 +1,69 @@
import React, { ReactNode, useContext } from "react";
import { AppContext } from "context/app";
import ExternalURLIcon from "../../../assets/images/icon-external-url-12x12@2x.png";
interface ISandboxErrorMessageProps {
message: string;
utmSource: string;
}
const baseClass = "sandbox-error-message";
const SandboxErrorMessage = ({
message,
utmSource,
}: ISandboxErrorMessageProps) => {
return (
<div className={baseClass}>
<h2 className={`${baseClass}__message`}>{message}</h2>
<p className={`${baseClass}__link-message`}>
Want to learn more?
<a
href={`https://calendly.com/fleetdm/demo?utm_source=${utmSource}`}
target="_blank"
rel="noreferrer"
>
Schedule a demo
</a>
<img
alt="Open external link"
className="icon-external"
src={ExternalURLIcon}
/>
</p>
</div>
);
};
interface ISandboxGateProps {
/** message to display in the sandbox error */
message: string;
/** UTM (Urchin Tracking Module) source text that is added to the demo link */
utmSource: string;
children: ReactNode;
}
/**
* Checks for and conditionally renders children content depending on a sandbox
* mode check
*/
const SandboxGate = ({
message,
utmSource,
children,
}: ISandboxGateProps): JSX.Element => {
const { isSandboxMode } = useContext(AppContext);
return (
<>
{isSandboxMode ? (
<SandboxErrorMessage message={message} utmSource={utmSource} />
) : (
<>{children}</>
)}
</>
);
};
export default SandboxGate;

View File

@ -0,0 +1,27 @@
.sandbox-error-message {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
width: 300px;
margin: auto;
&__message {
font-size: $small;
font-weight: $bold;
}
&__link-message {
font-size: $x-small;
margin: 0;
a {
padding-left: $pad-xsmall;
}
img {
width: 12px;
padding-left: $pad-xsmall;
}
}
}

View File

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

View File

@ -1,6 +1,7 @@
import React, { useState, useContext, useEffect } from "react";
import React, { useState, useContext, useEffect, ReactElement } from "react";
import { InjectedRouter } from "react-router";
import { formatDistanceToNow } from "date-fns";
import classnames from "classnames";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
@ -28,6 +29,7 @@ import Modal from "components/Modal";
import UserSettingsForm from "components/forms/UserSettingsForm";
import InfoBanner from "components/InfoBanner";
import SecretField from "components/SecretField";
import SandboxGate from "components/SandboxGate";
import ExternalURLIcon from "../../../assets/images/icon-external-url-12x12@2x.png";
const baseClass = "user-settings";
@ -36,9 +38,14 @@ interface IUserSettingsPageProps {
router: InjectedRouter;
}
const UserSettingsPage = ({ router }: IUserSettingsPageProps) => {
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const UserSettingsPage = ({
router,
}: IUserSettingsPageProps): JSX.Element | null => {
const { config, currentUser, isPremiumTier, isSandboxMode } = useContext(
AppContext
);
const { renderFlash } = useContext(NotificationContext);
const [pendingEmail, setPendingEmail] = useState<string>("");
const [showEmailModal, setShowEmailModal] = useState<boolean>(false);
const [showPasswordModal, setShowPasswordModal] = useState<boolean>(false);
@ -95,11 +102,6 @@ const UserSettingsPage = ({ router }: IUserSettingsPageProps) => {
return false;
};
const onToggleSecret = () => {
setRevealSecret(!revealSecret);
return false;
};
// placeholder is needed even though it's not used
const onCopySecret = (placeholder: string) => {
return (evt: ClipboardEvent) => {
@ -284,7 +286,7 @@ const UserSettingsPage = ({ router }: IUserSettingsPageProps) => {
};
if (!currentUser) {
return false;
return null;
}
const {
@ -303,81 +305,92 @@ const UserSettingsPage = ({ router }: IUserSettingsPageProps) => {
addSuffix: true,
});
const wrapperStyles = classnames(baseClass, {
[`${baseClass}__sandboxMode`]: isSandboxMode,
});
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 className={wrapperStyles}>
<SandboxGate
message="Account management is only available in self-managed Fleet"
utmSource="fleet-ui-my-account-page"
>
<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>
{isPremiumTier && (
<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`}>Teams</p>
<p className={`${baseClass}__header`}>Role</p>
<p
className={`${baseClass}__description ${baseClass}__teams ${greyCell(
teamsText
className={`${baseClass}__description ${baseClass}__role ${greyCell(
roleText
)}`}
>
{teamsText}
{roleText}
</p>
</div>
)}
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Role</p>
<p
className={`${baseClass}__description ${baseClass}__role ${greyCell(
roleText
)}`}
<div className={`${baseClass}__more-info-detail`}>
<p className={`${baseClass}__header`}>Password</p>
</div>
<Button
onClick={onShowPasswordModal}
disabled={ssoEnabled}
className={`${baseClass}__button`}
>
{roleText}
Change password
</Button>
<p className={`${baseClass}__last-updated`}>
Last changed: {lastUpdatedAt}
</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"
<Button
onClick={onShowApiTokenModal}
className={`${baseClass}__button`}
>
Privacy policy
</a>
</span>
</div>
{renderEmailModal()}
{renderPasswordModal()}
{renderApiTokenModal()}
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()}
</SandboxGate>
</div>
);
};

View File

@ -2,6 +2,10 @@
display: flex;
align-items: stretch;
&__sandboxMode {
margin-top: 70px;
}
h1 {
margin: 0 0 $pad-xlarge;
}
@ -135,7 +139,7 @@
top: 1px;
}
}
.secret-field__secret-input {
margin-bottom: 8px;
}

View File

@ -1,26 +1,8 @@
import React, { useCallback, useContext, useState, useEffect } from "react";
import { useErrorHandler } from "react-error-boundary";
import { useQuery } from "react-query";
import React from "react";
import { Params } from "react-router/lib/Router";
import { Link } from "react-router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import configAPI from "services/entities/config";
import deepDifference from "utilities/deep_difference";
import { IConfig } from "interfaces/config";
import { IApiError } from "interfaces/errors";
import Spinner from "components/Spinner";
import PATHS from "router/paths";
import Info from "./cards/Info";
import WebAddress from "./cards/WebAddress";
import Sso from "./cards/Sso";
import Smtp from "./cards/Smtp";
import AgentOptions from "./cards/Agents";
import HostStatusWebhook from "./cards/HostStatusWebhook";
import Statistics from "./cards/Statistics";
import Advanced from "./cards/Advanced";
import FleetDesktop from "./cards/FleetDesktop";
import SandboxGate from "components/SandboxGate";
import OrgSettingsForm from "./components/OrgSettingsForm";
interface IAppSettingsPageProps {
params: Params;
@ -28,227 +10,20 @@ interface IAppSettingsPageProps {
export const baseClass = "app-settings";
const AppSettingsPage = ({
params: { section: sectionTitle },
}: IAppSettingsPageProps): JSX.Element => {
const { isFreeTier, isPremiumTier, setConfig } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const handlePageError = useErrorHandler();
const [activeSection, setActiveSection] = useState<string>("info");
const { data: appConfig, isLoading, refetch: refetchConfig } = useQuery<
IConfig,
Error,
IConfig
>(["config"], () => configAPI.loadAll(), {
select: (data: IConfig) => data,
onSuccess: (data) => {
setConfig(data);
},
});
const isNavItemActive = (navItem: string) => {
return navItem === activeSection ? "active-nav" : "";
};
const onFormSubmit = useCallback(
(formData: Partial<IConfig>) => {
if (!appConfig) {
return false;
}
const diff = deepDifference(formData, appConfig);
// send all formData.agent_options because diff overrides all agent options
diff.agent_options = formData.agent_options;
configAPI
.update(diff)
.then(() => {
renderFlash("success", "Successfully updated settings.");
})
.catch((response: { data: IApiError }) => {
if (
response?.data.errors[0].reason.includes("could not dial smtp host")
) {
renderFlash(
"error",
"Could not connect to SMTP server. Please try again."
);
} else if (response?.data.errors) {
renderFlash(
"error",
`Could not update settings. ${response.data.errors[0].reason}`
);
}
})
.finally(() => {
refetchConfig();
});
},
[appConfig]
);
useEffect(() => {
if (isFreeTier && sectionTitle === "fleet-desktop") {
handlePageError({ status: 403 });
}
if (sectionTitle) {
setActiveSection(sectionTitle);
}
}, [isFreeTier, sectionTitle]);
const renderSection = () => {
if (!isLoading && appConfig) {
return (
<>
{activeSection === "info" && (
<Info appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "webaddress" && (
<WebAddress appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "sso" && (
<Sso appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "smtp" && (
<Smtp appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "agents" && (
<AgentOptions appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "host-status-webhook" && (
<HostStatusWebhook
appConfig={appConfig}
handleSubmit={onFormSubmit}
/>
)}
{activeSection === "statistics" && (
<Statistics appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "advanced" && (
<Advanced appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{isPremiumTier && activeSection === "fleet-desktop" && (
<FleetDesktop
appConfig={appConfig}
isPremiumTier={isPremiumTier}
handleSubmit={onFormSubmit}
/>
)}
</>
);
}
return <></>;
};
const AppSettingsPage = ({ params }: IAppSettingsPageProps): JSX.Element => {
const { section } = params;
return (
<div className={`${baseClass} body-wrap`}>
<p className={`${baseClass}__page-description`}>
Set your organization information and configure SSO and SMTP
</p>
{isLoading ? (
<Spinner />
) : (
<div className={`${baseClass}__settings-form`}>
<nav>
<ul className={`${baseClass}__form-nav-list`}>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive("info")}
}`}
to={PATHS.ADMIN_SETTINGS_INFO}
>
Organization info
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"webaddress"
)}`}
to={PATHS.ADMIN_SETTINGS_WEBADDRESS}
>
Fleet web address
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive("sso")}`}
to={PATHS.ADMIN_SETTINGS_SSO}
>
Single sign-on options
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link$ ${isNavItemActive(
"smtp"
)}`}
to={PATHS.ADMIN_SETTINGS_SMTP}
>
SMTP options
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"agents"
)}`}
to={PATHS.ADMIN_SETTINGS_AGENTS}
>
Global agent options
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"host-status-webhook"
)}`}
to={PATHS.ADMIN_SETTINGS_HOST_STATUS_WEBHOOK}
>
Host status webhook
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"statistics"
)}`}
to={PATHS.ADMIN_SETTINGS_STATISTICS}
>
Usage statistics
</Link>
</li>
{isPremiumTier && (
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"fleet-desktop"
)}`}
to={PATHS.ADMIN_SETTINGS_FLEET_DESKTOP}
>
Fleet Desktop
</Link>
</li>
)}
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"advanced"
)}`}
to={PATHS.ADMIN_SETTINGS_ADVANCED}
>
Advanced options
</Link>
</li>
</ul>
</nav>
{renderSection()}
</div>
)}
<SandboxGate
message="Organization settings are only available in self-managed Fleet"
utmSource="fleet-ui-organization-settings-page"
>
<OrgSettingsForm section={section} />
</SandboxGate>
</div>
);
};

View File

@ -6,307 +6,4 @@
color: $core-fleet-black;
@include sticky-settings-description;
}
h2 {
font-size: $medium;
font-weight: $regular;
color: $core-fleet-black;
border-bottom: solid 1px $ui-fleet-blue-15;
margin: 0 0 $pad-xxlarge;
}
small {
font-size: $x-small;
font-weight: $regular;
color: $core-fleet-black;
}
&__settings-form {
display: flex;
}
&__form-nav-list {
position: -webkit-sticky;
position: sticky;
// this is the spacing needed to make the sticky form nav position correctly when scrolling
// TODO: find a way to calculate these sticky positions this and use variables.
// will be tedious to update otherwise.
top: 217px;
width: 178px;
margin: 0;
padding: 0 110px 0 0;
list-style: none;
font-size: $x-small;
li {
margin-bottom: $pad-medium;
}
a {
color: $core-fleet-black;
font-weight: $regular;
text-decoration: none;
cursor: pointer;
&:hover {
color: $core-vibrant-blue;
}
}
.active-nav {
font-weight: $bold;
}
}
.form-field__label {
.buttons {
top: 22px;
}
}
}
.app-config-form {
width: 100%;
&__config-docs {
display: flex;
flex-direction: row;
align-items: center;
padding: $pad-medium;
border-radius: $border-radius;
border: 1px solid #d9d9fe;
background-color: $ui-vibrant-blue-10;
margin-bottom: $pad-medium;
font-size: $x-small;
p {
margin: 0;
font-size: $x-small;
}
}
&__learn-more {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
&--inline {
margin-left: $pad-medium;
}
img {
width: 12px;
height: 12px;
margin-left: $pad-small;
}
}
.form-field {
width: 100%;
&__label {
font-weight: $bold;
margin-bottom: $pad-xsmall;
width: 100%;
}
&__hint {
font-size: $x-small;
font-style: italic;
}
}
&__section {
@include clearfix;
margin: 0 0 $pad-large;
.upcaret::after {
content: url("../assets/images/icon-collapse-black-16x16@2x.png");
transform: scale(0.5);
border-radius: 0px;
position: relative;
top: 8px;
margin: $pad-xsmall;
}
.downcaret::after {
content: url("../assets/images/icon-chevron-black-16x16@2x.png");
transform: scale(0.5);
border-radius: 0px;
position: relative;
top: 8px;
margin: $pad-xsmall;
margin-right: 18px;
}
h2 {
padding-bottom: $pad-small;
max-width: 65%;
}
.smtp-options {
font-size: 15px;
font-weight: $bold;
color: $core-fleet-black;
padding-left: 15px;
em {
font-style: normal;
}
&--configured {
em {
color: $ui-success;
}
}
&--notconfigured {
em {
color: $ui-error;
}
}
}
}
&__section-description {
font-size: $x-small;
color: $core-fleet-black;
width: 60%;
}
&__yaml {
.app-config-form__section-description {
width: initial;
}
h2 {
max-width: initial;
}
}
&__inputs {
width: 60%;
float: left;
padding-right: $pad-small;
box-sizing: border-box;
.input-field {
width: 100%;
}
&--smtp {
margin: 0 0 $pad-medium;
.form-field {
width: 29%;
float: right;
&:first-child {
float: left;
width: 69%;
}
&:nth-child(2) {
padding-top: 10px;
}
&--checkbox {
clear: both;
width: 100%;
float: none;
}
}
}
&--usage {
margin-top: $pad-large;
}
&--preview {
margin: $pad-medium 0;
}
&--webhook {
margin-bottom: $pad-large;
}
}
&__details {
float: right;
width: 40%;
height: 87px;
.icon-tooltip {
margin: $pad-xlarge 0;
}
.hint {
color: $core-fleet-black;
&--brand {
color: $core-vibrant-blue;
}
}
}
&__avatar-preview {
text-align: center;
img {
border-radius: 20%;
height: 120px;
width: 120px;
border: 1px solid $ui-fleet-blue-15;
background-color: $ui-light-grey;
position: relative;
bottom: -20px;
}
p {
color: $core-fleet-purple;
font-size: 18px;
font-weight: $bold;
margin-top: 0;
}
}
&__smtp-section {
@include clearfix;
}
&__component-label {
margin: 24px 0 0;
font-size: $x-small;
}
&__yaml {
width: calc(90% - 50px);
margin-bottom: $pad-medium;
}
&__usage-stats-preview-modal,
&__host-status-webhook-preview-modal {
.flex-end {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
&__advanced-options {
margin-bottom: $pad-large;
}
&__transparency {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
img {
width: 12px;
height: 12px;
}
}
.component__tooltip-wrapper {
margin-bottom: $pad-xsmall;
}
}

View File

@ -0,0 +1,252 @@
import React, { useCallback, useContext, useState, useEffect } from "react";
import { useErrorHandler } from "react-error-boundary";
import { useQuery } from "react-query";
import { Link } from "react-router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import configAPI from "services/entities/config";
import deepDifference from "utilities/deep_difference";
import { IConfig } from "interfaces/config";
import { IApiError } from "interfaces/errors";
import Spinner from "components/Spinner";
import PATHS from "router/paths";
import Info from "../../cards/Info";
import WebAddress from "../../cards/WebAddress";
import Sso from "../../cards/Sso";
import Smtp from "../../cards/Smtp";
import AgentOptions from "../../cards/Agents";
import HostStatusWebhook from "../../cards/HostStatusWebhook";
import Statistics from "../../cards/Statistics";
import Advanced from "../../cards/Advanced";
import FleetDesktop from "../../cards/FleetDesktop";
interface IOrgSettingsForm {
section: string;
}
export const baseClass = "org-settings-form";
const OrgSettingsForm = ({
section: sectionTitle,
}: IOrgSettingsForm): JSX.Element => {
const { isFreeTier, isPremiumTier, setConfig } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const handlePageError = useErrorHandler();
const [activeSection, setActiveSection] = useState<string>("info");
const { data: appConfig, isLoading, refetch: refetchConfig } = useQuery<
IConfig,
Error,
IConfig
>(["config"], () => configAPI.loadAll(), {
select: (data: IConfig) => data,
onSuccess: (data) => {
setConfig(data);
},
});
const isNavItemActive = (navItem: string) => {
return navItem === activeSection ? "active-nav" : "";
};
const onFormSubmit = useCallback(
(formData: Partial<IConfig>) => {
if (!appConfig) {
return false;
}
const diff = deepDifference(formData, appConfig);
// send all formData.agent_options because diff overrides all agent options
diff.agent_options = formData.agent_options;
configAPI
.update(diff)
.then(() => {
renderFlash("success", "Successfully updated settings.");
})
.catch((response: { data: IApiError }) => {
if (
response?.data.errors[0].reason.includes("could not dial smtp host")
) {
renderFlash(
"error",
"Could not connect to SMTP server. Please try again."
);
} else if (response?.data.errors) {
renderFlash(
"error",
`Could not update settings. ${response.data.errors[0].reason}`
);
}
})
.finally(() => {
refetchConfig();
});
},
[appConfig]
);
useEffect(() => {
if (isFreeTier && sectionTitle === "fleet-desktop") {
handlePageError({ status: 403 });
}
if (sectionTitle) {
setActiveSection(sectionTitle);
}
}, [isFreeTier, sectionTitle]);
const renderSection = () => {
if (!isLoading && appConfig) {
return (
<>
{activeSection === "info" && (
<Info appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "webaddress" && (
<WebAddress appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "sso" && (
<Sso appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "smtp" && (
<Smtp appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "agents" && (
<AgentOptions appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "host-status-webhook" && (
<HostStatusWebhook
appConfig={appConfig}
handleSubmit={onFormSubmit}
/>
)}
{activeSection === "statistics" && (
<Statistics appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{activeSection === "advanced" && (
<Advanced appConfig={appConfig} handleSubmit={onFormSubmit} />
)}
{isPremiumTier && activeSection === "fleet-desktop" && (
<FleetDesktop
appConfig={appConfig}
isPremiumTier={isPremiumTier}
handleSubmit={onFormSubmit}
/>
)}
</>
);
}
return <></>;
};
return (
<div className={`${baseClass}`}>
{isLoading ? (
<Spinner />
) : (
<div className={`${baseClass}__settings-form`}>
<nav>
<ul className={`${baseClass}__form-nav-list`}>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive("info")}
}`}
to={PATHS.ADMIN_SETTINGS_INFO}
>
Organization info
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"webaddress"
)}`}
to={PATHS.ADMIN_SETTINGS_WEBADDRESS}
>
Fleet web address
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive("sso")}`}
to={PATHS.ADMIN_SETTINGS_SSO}
>
Single sign-on options
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link$ ${isNavItemActive(
"smtp"
)}`}
to={PATHS.ADMIN_SETTINGS_SMTP}
>
SMTP options
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"agents"
)}`}
to={PATHS.ADMIN_SETTINGS_AGENTS}
>
Global agent options
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"host-status-webhook"
)}`}
to={PATHS.ADMIN_SETTINGS_HOST_STATUS_WEBHOOK}
>
Host status webhook
</Link>
</li>
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"statistics"
)}`}
to={PATHS.ADMIN_SETTINGS_STATISTICS}
>
Usage statistics
</Link>
</li>
{isPremiumTier && (
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"fleet-desktop"
)}`}
to={PATHS.ADMIN_SETTINGS_FLEET_DESKTOP}
>
Fleet Desktop
</Link>
</li>
)}
<li>
<Link
className={`${baseClass}__nav-link ${isNavItemActive(
"advanced"
)}`}
to={PATHS.ADMIN_SETTINGS_ADVANCED}
>
Advanced options
</Link>
</li>
</ul>
</nav>
{renderSection()}
</div>
)}
</div>
);
};
export default OrgSettingsForm;

View File

@ -0,0 +1,300 @@
.org-settings-form {
&__settings-form {
display: flex;
}
&__form-nav-list {
position: -webkit-sticky;
position: sticky;
// this is the spacing needed to make the sticky form nav position correctly when scrolling
// TODO: find a way to calculate these sticky positions this and use variables.
// will be tedious to update otherwise.
top: 217px;
width: 178px;
margin: 0;
padding: 0 110px 0 0;
list-style: none;
font-size: $x-small;
li {
margin-bottom: $pad-medium;
}
a {
color: $core-fleet-black;
font-weight: $regular;
text-decoration: none;
cursor: pointer;
&:hover {
color: $core-vibrant-blue;
}
}
.active-nav {
font-weight: $bold;
}
}
.form-field__label {
.buttons {
top: 22px;
}
}
}
.app-config-form {
width: 100%;
&__config-docs {
display: flex;
flex-direction: row;
align-items: center;
padding: $pad-medium;
border-radius: $border-radius;
border: 1px solid #d9d9fe;
background-color: $ui-vibrant-blue-10;
margin-bottom: $pad-medium;
font-size: $x-small;
p {
margin: 0;
font-size: $x-small;
}
}
&__learn-more {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
&--inline {
margin-left: $pad-medium;
}
img {
width: 12px;
height: 12px;
margin-left: $pad-small;
}
}
.form-field {
width: 100%;
&__label {
font-weight: $bold;
margin-bottom: $pad-xsmall;
width: 100%;
}
&__hint {
font-size: $x-small;
font-style: italic;
}
}
&__section {
@include clearfix;
margin: 0 0 $pad-large;
.upcaret::after {
content: url("../assets/images/icon-collapse-black-16x16@2x.png");
transform: scale(0.5);
border-radius: 0px;
position: relative;
top: 8px;
margin: $pad-xsmall;
}
.downcaret::after {
content: url("../assets/images/icon-chevron-black-16x16@2x.png");
transform: scale(0.5);
border-radius: 0px;
position: relative;
top: 8px;
margin: $pad-xsmall;
margin-right: 18px;
}
h2 {
padding-bottom: $pad-small;
max-width: 65%;
font-size: $medium;
font-weight: $regular;
color: $core-fleet-black;
border-bottom: solid 1px $ui-fleet-blue-15;
margin: 0 0 $pad-xxlarge;
}
.smtp-options {
font-size: 15px;
font-weight: $bold;
color: $core-fleet-black;
padding-left: 15px;
em {
font-style: normal;
}
&--configured {
em {
color: $ui-success;
}
}
&--notconfigured {
em {
color: $ui-error;
}
}
}
}
&__section-description {
font-size: $x-small;
color: $core-fleet-black;
width: 60%;
}
&__yaml {
.app-config-form__section-description {
width: initial;
}
h2 {
max-width: initial;
}
}
&__inputs {
width: 60%;
float: left;
padding-right: $pad-small;
box-sizing: border-box;
.input-field {
width: 100%;
}
&--smtp {
margin: 0 0 $pad-medium;
.form-field {
width: 29%;
float: right;
&:first-child {
float: left;
width: 69%;
}
&:nth-child(2) {
padding-top: 10px;
}
&--checkbox {
clear: both;
width: 100%;
float: none;
}
}
}
&--usage {
margin-top: $pad-large;
}
&--preview {
margin: $pad-medium 0;
}
&--webhook {
margin-bottom: $pad-large;
}
}
&__details {
float: right;
width: 40%;
height: 87px;
.icon-tooltip {
margin: $pad-xlarge 0;
}
.hint {
color: $core-fleet-black;
&--brand {
color: $core-vibrant-blue;
}
}
}
&__avatar-preview {
text-align: center;
img {
border-radius: 20%;
height: 120px;
width: 120px;
border: 1px solid $ui-fleet-blue-15;
background-color: $ui-light-grey;
position: relative;
bottom: -20px;
}
p {
color: $core-fleet-purple;
font-size: 18px;
font-weight: $bold;
margin-top: 0;
}
}
&__smtp-section {
@include clearfix;
}
&__component-label {
margin: 24px 0 0;
font-size: $x-small;
}
&__yaml {
width: calc(90% - 50px);
margin-bottom: $pad-medium;
}
&__usage-stats-preview-modal,
&__host-status-webhook-preview-modal {
.flex-end {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
&__advanced-options {
margin-bottom: $pad-large;
}
&__transparency {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
img {
width: 12px;
height: 12px;
}
}
.component__tooltip-wrapper {
margin-bottom: $pad-xsmall;
}
}

View File

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

View File

@ -1,33 +1,7 @@
import React, { useState, useCallback, useContext, useEffect } from "react";
import React from "react";
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";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import usersAPI from "services/entities/users";
import invitesAPI from "services/entities/invites";
import TableContainer, { ITableQueryData } from "components/TableContainer";
import TableDataError from "components/DataError";
import Modal from "components/Modal";
import { DEFAULT_CREATE_USER_ERRORS } from "utilities/constants";
import EmptyUsers from "./components/EmptyUsers";
import { generateTableHeaders, combineDataSets } from "./UsersTableConfig";
import DeleteUserForm from "./components/DeleteUserForm";
import ResetPasswordModal from "./components/ResetPasswordModal";
import ResetSessionsModal from "./components/ResetSessionsModal";
import { NewUserType } from "./components/UserForm/UserForm";
import CreateUserModal from "./components/CreateUserModal";
import EditUserModal from "./components/EditUserModal";
import SandboxGate from "components/SandboxGate";
import UsersTable from "./components/UsersTable";
const baseClass = "user-management";
@ -36,543 +10,18 @@ interface IUserManagementProps {
}
const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
// STATES
const [showCreateUserModal, setShowCreateUserModal] = useState<boolean>(
false
);
const [showEditUserModal, setShowEditUserModal] = useState<boolean>(false);
const [showDeleteUserModal, setShowDeleteUserModal] = useState<boolean>(
false
);
const [showResetPasswordModal, setShowResetPasswordModal] = useState<boolean>(
false
);
const [showResetSessionsModal, setShowResetSessionsModal] = useState<boolean>(
false
);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isEditingUser, setIsEditingUser] = useState<boolean>(false);
const [userEditing, setUserEditing] = useState<any>(null);
const [createUserErrors, setCreateUserErrors] = useState<IUserFormErrors>(
DEFAULT_CREATE_USER_ERRORS
);
const [editUserErrors, setEditUserErrors] = useState<IUserFormErrors>(
DEFAULT_CREATE_USER_ERRORS
);
const [querySearchText, setQuerySearchText] = useState<string>("");
// API CALLS
const {
data: teams,
isFetching: isFetchingTeams,
error: loadingTeamsError,
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
["teams"],
() => teamsAPI.loadAll(),
{
enabled: !!isPremiumTier,
select: (data: ILoadTeamsResponse) => data.teams,
}
);
const {
data: users,
isFetching: isFetchingUsers,
error: loadingUsersError,
refetch: refetchUsers,
} = useQuery<IUser[], Error, IUser[]>(
["users", querySearchText],
() => usersAPI.loadAll({ globalFilter: querySearchText }),
{
select: (data: IUser[]) => data,
}
);
const {
data: invites,
isFetching: isFetchingInvites,
error: loadingInvitesError,
refetch: refetchInvites,
} = useQuery<IInvite[], Error, IInvite[]>(
["invites", querySearchText],
() => invitesAPI.loadAll({ globalFilter: querySearchText }),
{
select: (data: IInvite[]) => {
return data;
},
}
);
// TOGGLE MODALS
const toggleCreateUserModal = useCallback(() => {
setShowCreateUserModal(!showCreateUserModal);
// clear errors on close
if (!showCreateUserModal) {
setCreateUserErrors(DEFAULT_CREATE_USER_ERRORS);
}
}, [showCreateUserModal, setShowCreateUserModal]);
const toggleDeleteUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowDeleteUserModal(!showDeleteUserModal);
setUserEditing(!showDeleteUserModal ? user : null);
},
[showDeleteUserModal, setShowDeleteUserModal, setUserEditing]
);
const toggleEditUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowEditUserModal(!showEditUserModal);
setUserEditing(!showEditUserModal ? user : null);
setEditUserErrors(DEFAULT_CREATE_USER_ERRORS);
},
[showEditUserModal, setShowEditUserModal, setUserEditing]
);
const toggleResetPasswordUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowResetPasswordModal(!showResetPasswordModal);
setUserEditing(!showResetPasswordModal ? user : null);
},
[showResetPasswordModal, setShowResetPasswordModal, setUserEditing]
);
const toggleResetSessionsUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowResetSessionsModal(!showResetSessionsModal);
setUserEditing(!showResetSessionsModal ? user : null);
},
[showResetSessionsModal, setShowResetSessionsModal, setUserEditing]
);
// FUNCTIONS
const combineUsersAndInvites = memoize(
(usersData, invitesData, currentUserId) => {
return combineDataSets(usersData, invitesData, currentUserId);
}
);
const goToUserSettingsPage = () => {
const { USER_SETTINGS } = paths;
router.push(USER_SETTINGS);
};
// NOTE: this is called once on the initial rendering. The initial render of
// the TableContainer child component calls this handler.
const onTableQueryChange = (queryData: ITableQueryData) => {
const { searchQuery, sortHeader, sortDirection } = queryData;
let sortBy: any = []; // TODO
if (sortHeader !== "") {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
setQuerySearchText(searchQuery);
refetchUsers();
refetchInvites();
};
const onActionSelect = (value: string, user: IUser | IInvite) => {
switch (value) {
case "edit":
toggleEditUserModal(user);
break;
case "delete":
toggleDeleteUserModal(user);
break;
case "passwordReset":
toggleResetPasswordUserModal(user);
break;
case "resetSessions":
toggleResetSessionsUserModal(user);
break;
case "editMyAccount":
goToUserSettingsPage();
break;
default:
return null;
}
return null;
};
const getUser = (type: string, id: number) => {
let userData;
if (type === "user") {
userData = users?.find((user) => user.id === id);
} else {
userData = invites?.find((invite) => invite.id === id);
}
return userData;
};
const onCreateUserSubmit = (formData: any) => {
setIsLoading(true);
if (formData.newUserType === NewUserType.AdminInvited) {
// Do some data formatting adding `invited_by` for the request to be correct and deleteing uncessary fields
const requestData = {
...formData,
invited_by: formData.currentUserId,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
delete requestData.password; // this field is not needed for the request
invitesAPI
.create(requestData)
.then(() => {
renderFlash(
"success",
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.`
);
toggleCreateUserModal();
refetchInvites();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setCreateUserErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setCreateUserErrors({
password: "Password must meet the criteria below",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsLoading(false);
});
} else {
// Do some data formatting deleting unnecessary fields
const requestData = {
...formData,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
usersAPI
.createUserWithoutInvitation(requestData)
.then(() => {
renderFlash("success", `Successfully created ${requestData.name}.`);
toggleCreateUserModal();
refetchUsers();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("Duplicate")) {
setCreateUserErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setCreateUserErrors({
password: "Password must meet the criteria below",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsLoading(false);
});
}
};
const onEditUser = (formData: any) => {
const userData = getUser(userEditing.type, userEditing.id);
let userUpdatedFlashMessage = `Successfully edited ${formData.name}`;
if (userData?.email !== formData.email) {
userUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}`;
}
const userUpdatedEmailError =
"A user with this email address already exists";
const userUpdatedPasswordError = "Password must meet the criteria below";
const userUpdatedError = `Could not edit ${userEditing?.name}. Please try again.`;
setIsEditingUser(true);
if (userEditing.type === "invite") {
return (
userData &&
invitesAPI
.update(userData.id, formData)
.then(() => {
renderFlash("success", userUpdatedFlashMessage);
toggleEditUserModal();
refetchInvites();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setEditUserErrors({
email: userUpdatedEmailError,
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setEditUserErrors({
password: userUpdatedPasswordError,
});
} else {
renderFlash("error", userUpdatedError);
}
})
.finally(() => {
setIsEditingUser(false);
})
);
}
return (
userData &&
usersAPI
.update(userData.id, formData)
.then(() => {
renderFlash("success", userUpdatedFlashMessage);
toggleEditUserModal();
refetchUsers();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setEditUserErrors({
email: userUpdatedEmailError,
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setEditUserErrors({
password: userUpdatedPasswordError,
});
} else {
renderFlash("error", userUpdatedError);
}
})
.finally(() => {
setIsEditingUser(false);
})
);
};
const onDeleteUser = () => {
if (userEditing.type === "invite") {
invitesAPI
.destroy(userEditing.id)
.then(() => {
renderFlash("success", `Successfully deleted ${userEditing?.name}.`);
})
.catch(() => {
renderFlash(
"error",
`Could not delete ${userEditing?.name}. Please try again.`
);
})
.finally(() => {
toggleDeleteUserModal();
refetchInvites();
});
} else {
usersAPI
.destroy(userEditing.id)
.then(() => {
renderFlash("success", `Successfully deleted ${userEditing?.name}.`);
})
.catch(() => {
renderFlash(
"error",
`Could not delete ${userEditing?.name}. Please try again.`
);
})
.finally(() => {
toggleDeleteUserModal();
refetchUsers();
});
}
};
const onResetSessions = () => {
const isResettingCurrentUser = currentUser?.id === userEditing.id;
usersAPI
.deleteSessions(userEditing.id)
.then(() => {
if (isResettingCurrentUser) {
clearToken();
setTimeout(() => {
window.location.href = "/";
}, 500);
return;
}
renderFlash("success", "Successfully reset sessions.");
})
.catch(() => {
renderFlash("error", "Could not reset sessions. Please try again.");
})
.finally(() => {
toggleResetSessionsUserModal();
});
};
const resetPassword = (user: IUser) => {
return usersAPI
.requirePasswordReset(user.id, { require: true })
.then(() => {
renderFlash("success", "Successfully required a password reset.");
})
.catch(() => {
renderFlash(
"error",
"Could not require a password reset. Please try again."
);
})
.finally(() => {
toggleResetPasswordUserModal();
});
};
const renderEditUserModal = () => {
const userData = getUser(userEditing.type, userEditing.id);
return (
<Modal
title="Edit user"
onExit={toggleEditUserModal}
className={`${baseClass}__edit-user-modal`}
>
<>
<EditUserModal
defaultEmail={userData?.email}
defaultName={userData?.name}
defaultGlobalRole={userData?.global_role}
defaultTeams={userData?.teams}
onCancel={toggleEditUserModal}
onSubmit={onEditUser}
availableTeams={teams || []}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings.configured || false}
canUseSso={config?.sso_settings.enable_sso || false}
isSsoEnabled={userData?.sso_enabled}
isModifiedByGlobalAdmin
isInvitePending={userEditing.type === "invite"}
editUserErrors={editUserErrors}
isLoading={isEditingUser}
/>
</>
</Modal>
);
};
const renderCreateUserModal = () => {
return (
<CreateUserModal
createUserErrors={createUserErrors}
onCancel={toggleCreateUserModal}
onSubmit={onCreateUserSubmit}
availableTeams={teams || []}
defaultGlobalRole={"observer"}
defaultTeams={[]}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings.configured || false}
canUseSso={config?.sso_settings.enable_sso || false}
isLoading={isLoading}
isModifiedByGlobalAdmin
/>
);
};
const renderDeleteUserModal = () => {
return (
<Modal
title={"Delete user"}
onExit={toggleDeleteUserModal}
className={`${baseClass}__delete-user-modal`}
>
<DeleteUserForm
name={userEditing.name}
onDelete={onDeleteUser}
onCancel={toggleDeleteUserModal}
/>
</Modal>
);
};
const renderResetPasswordModal = () => {
return (
<ResetPasswordModal
user={userEditing}
modalBaseClass={baseClass}
onResetConfirm={resetPassword}
onResetCancel={toggleResetPasswordUserModal}
/>
);
};
const renderResetSessionsModal = () => {
return (
<ResetSessionsModal
user={userEditing}
modalBaseClass={baseClass}
onResetConfirm={onResetSessions}
onResetCancel={toggleResetSessionsUserModal}
/>
);
};
const tableHeaders = generateTableHeaders(
onActionSelect,
isPremiumTier || false
);
const loadingTableData =
isFetchingUsers || isFetchingInvites || isFetchingTeams;
const tableDataError =
loadingUsersError || loadingInvitesError || loadingTeamsError;
let tableData: unknown = [];
if (!loadingTableData && !tableDataError) {
tableData = combineUsersAndInvites(users, invites, currentUser?.id);
}
return (
<div className={`${baseClass} body-wrap`}>
<p className={`${baseClass}__page-description`}>
Create new users, customize user permissions, and remove users from
Fleet.
</p>
{/* TODO: find a way to move these controls into the table component */}
{tableDataError ? (
<TableDataError />
) : (
<TableContainer
columns={tableHeaders}
data={tableData}
isLoading={loadingTableData}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Search"}
actionButtonText={"Create user"}
onActionButtonClick={toggleCreateUserModal}
onQueryChange={onTableQueryChange}
resultsTitle={"users"}
emptyComponent={EmptyUsers}
searchable
showMarkAllPages={false}
isAllPagesSelected={false}
isClientSidePagination
/>
)}
{showCreateUserModal && renderCreateUserModal()}
{showEditUserModal && renderEditUserModal()}
{showDeleteUserModal && renderDeleteUserModal()}
{showResetSessionsModal && renderResetSessionsModal()}
{showResetPasswordModal && renderResetPasswordModal()}
<SandboxGate
message="User management is only available in self-managed Fleet"
utmSource="fleet-ui-users-page"
>
<UsersTable router={router} />
</SandboxGate>
</div>
);
};

View File

@ -4,126 +4,8 @@
font-size: $x-small;
color: $core-fleet-black;
@include sticky-settings-description;
padding-bottom: $pad-medium;
}
&__wrapper {
border: solid 1px $ui-fleet-blue-15;
border-radius: 6px;
margin-top: $pad-medium;
}
&__header {
font-size: $large;
}
&__heading-wrapper {
width: 100%;
}
&__smtp-warning-wrapper {
width: 100%;
}
&__config-button {
text-decoration: none;
color: $core-vibrant-blue;
font-size: $x-small;
margin-left: $pad-medium;
}
&__user-count {
font-size: $x-small;
font-weight: $bold;
color: $core-fleet-black;
margin: 0;
}
&__add-user-wrap {
display: flex;
justify-content: space-between;
align-items: center;
}
&__users {
display: flex;
flex-wrap: wrap;
clear: both;
}
&__invite-modal {
&.modal__modal_container {
width: 540px;
}
}
.data-table-block {
.data-table__table {
thead {
.name__header {
width: $col-md;
}
.role__header {
width: $col-md;
}
.teams__header {
display: none;
width: 0;
}
.status__header {
display: none;
width: 0;
}
.actions__header {
width: auto;
}
@media (min-width: $break-990) {
.teams__header {
display: table-cell;
width: $col-md;
}
}
@media (min-width: $break-1400) {
.status__header {
display: table-cell;
width: $col-md;
}
}
}
tbody {
.name__cell {
max-width: $col-md;
}
.role__cell {
max-width: $col-md;
}
.teams__cell {
display: none;
max-width: $col-md;
.grey-cell {
color: $ui-fleet-black-50;
font-style: italic;
}
}
.status__cell {
display: none;
max-width: $col-md;
}
.actions__cell {
width: auto;
}
@media (min-width: $break-990) {
.teams__cell {
display: table-cell;
}
}
@media (min-width: $break-1400) {
.status__cell {
display: table-cell;
}
}
}
}
}
.Select-menu-outer {
position: absolute;
left: -50px !important; // Using !important to override react select styling

View File

@ -7,23 +7,17 @@ const baseClass = "reset-password-modal";
interface IResetPasswordModal {
user: IUser;
modalBaseClass: string;
onResetConfirm: (user: IUser) => void;
onResetCancel: () => void;
}
const ResetPasswordModal = ({
user,
modalBaseClass,
onResetConfirm,
onResetCancel,
}: IResetPasswordModal): JSX.Element => {
return (
<Modal
title="Require password reset"
onExit={onResetCancel}
className={`${modalBaseClass}__${baseClass}`}
>
<Modal title="Require password reset" onExit={onResetCancel}>
<div className={baseClass}>
<p>
This user will be asked to reset their password after their next

View File

@ -7,23 +7,17 @@ const baseClass = "reset-sessions-modal";
interface IResetSessionsModal {
user: IUser;
modalBaseClass: string;
onResetConfirm: (user: IUser) => void;
onResetCancel: () => void;
}
const ResetSessionsModal = ({
user,
modalBaseClass,
onResetConfirm,
onResetCancel,
}: IResetSessionsModal): JSX.Element => {
return (
<Modal
title="Reset sessions"
onExit={onResetCancel}
className={`${modalBaseClass}__${baseClass}`}
>
<Modal title="Reset sessions" onExit={onResetCancel}>
<div className={baseClass}>
<p>
This user will be logged out of Fleet.

View File

@ -0,0 +1,563 @@
import React, { useState, useCallback, useContext, useEffect } from "react";
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";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import usersAPI from "services/entities/users";
import invitesAPI from "services/entities/invites";
import TableContainer, { ITableQueryData } from "components/TableContainer";
import TableDataError from "components/DataError";
import Modal from "components/Modal";
import { DEFAULT_CREATE_USER_ERRORS } from "utilities/constants";
import EmptyUsers from "../EmptyUsers";
import { generateTableHeaders, combineDataSets } from "./UsersTableConfig";
import DeleteUserForm from "../DeleteUserForm";
import ResetPasswordModal from "../ResetPasswordModal";
import ResetSessionsModal from "../ResetSessionsModal";
import { NewUserType } from "../UserForm/UserForm";
import CreateUserModal from "../CreateUserModal";
import EditUserModal from "../EditUserModal";
interface IUsersTableProps {
router: InjectedRouter; // v3
}
const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
// STATES
const [showCreateUserModal, setShowCreateUserModal] = useState<boolean>(
false
);
const [showEditUserModal, setShowEditUserModal] = useState<boolean>(false);
const [showDeleteUserModal, setShowDeleteUserModal] = useState<boolean>(
false
);
const [showResetPasswordModal, setShowResetPasswordModal] = useState<boolean>(
false
);
const [showResetSessionsModal, setShowResetSessionsModal] = useState<boolean>(
false
);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isEditingUser, setIsEditingUser] = useState<boolean>(false);
const [userEditing, setUserEditing] = useState<any>(null);
const [createUserErrors, setCreateUserErrors] = useState<IUserFormErrors>(
DEFAULT_CREATE_USER_ERRORS
);
const [editUserErrors, setEditUserErrors] = useState<IUserFormErrors>(
DEFAULT_CREATE_USER_ERRORS
);
const [querySearchText, setQuerySearchText] = useState<string>("");
// API CALLS
const {
data: teams,
isFetching: isFetchingTeams,
error: loadingTeamsError,
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
["teams"],
() => teamsAPI.loadAll(),
{
enabled: !!isPremiumTier,
select: (data: ILoadTeamsResponse) => data.teams,
}
);
const {
data: users,
isFetching: isFetchingUsers,
error: loadingUsersError,
refetch: refetchUsers,
} = useQuery<IUser[], Error, IUser[]>(
["users", querySearchText],
() => usersAPI.loadAll({ globalFilter: querySearchText }),
{
select: (data: IUser[]) => data,
}
);
const {
data: invites,
isFetching: isFetchingInvites,
error: loadingInvitesError,
refetch: refetchInvites,
} = useQuery<IInvite[], Error, IInvite[]>(
["invites", querySearchText],
() => invitesAPI.loadAll({ globalFilter: querySearchText }),
{
select: (data: IInvite[]) => {
return data;
},
}
);
// TOGGLE MODALS
const toggleCreateUserModal = useCallback(() => {
setShowCreateUserModal(!showCreateUserModal);
// clear errors on close
if (!showCreateUserModal) {
setCreateUserErrors(DEFAULT_CREATE_USER_ERRORS);
}
}, [showCreateUserModal, setShowCreateUserModal]);
const toggleDeleteUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowDeleteUserModal(!showDeleteUserModal);
setUserEditing(!showDeleteUserModal ? user : null);
},
[showDeleteUserModal, setShowDeleteUserModal, setUserEditing]
);
const toggleEditUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowEditUserModal(!showEditUserModal);
setUserEditing(!showEditUserModal ? user : null);
setEditUserErrors(DEFAULT_CREATE_USER_ERRORS);
},
[showEditUserModal, setShowEditUserModal, setUserEditing]
);
const toggleResetPasswordUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowResetPasswordModal(!showResetPasswordModal);
setUserEditing(!showResetPasswordModal ? user : null);
},
[showResetPasswordModal, setShowResetPasswordModal, setUserEditing]
);
const toggleResetSessionsUserModal = useCallback(
(user?: IUser | IInvite) => {
setShowResetSessionsModal(!showResetSessionsModal);
setUserEditing(!showResetSessionsModal ? user : null);
},
[showResetSessionsModal, setShowResetSessionsModal, setUserEditing]
);
// FUNCTIONS
const combineUsersAndInvites = memoize(
(usersData, invitesData, currentUserId) => {
return combineDataSets(usersData, invitesData, currentUserId);
}
);
const goToUserSettingsPage = () => {
const { USER_SETTINGS } = paths;
router.push(USER_SETTINGS);
};
// NOTE: this is called once on the initial rendering. The initial render of
// the TableContainer child component calls this handler.
const onTableQueryChange = (queryData: ITableQueryData) => {
const { searchQuery, sortHeader, sortDirection } = queryData;
let sortBy: any = []; // TODO
if (sortHeader !== "") {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
setQuerySearchText(searchQuery);
refetchUsers();
refetchInvites();
};
const onActionSelect = (value: string, user: IUser | IInvite) => {
switch (value) {
case "edit":
toggleEditUserModal(user);
break;
case "delete":
toggleDeleteUserModal(user);
break;
case "passwordReset":
toggleResetPasswordUserModal(user);
break;
case "resetSessions":
toggleResetSessionsUserModal(user);
break;
case "editMyAccount":
goToUserSettingsPage();
break;
default:
return null;
}
return null;
};
const getUser = (type: string, id: number) => {
let userData;
if (type === "user") {
userData = users?.find((user) => user.id === id);
} else {
userData = invites?.find((invite) => invite.id === id);
}
return userData;
};
const onCreateUserSubmit = (formData: any) => {
setIsLoading(true);
if (formData.newUserType === NewUserType.AdminInvited) {
// Do some data formatting adding `invited_by` for the request to be correct and deleteing uncessary fields
const requestData = {
...formData,
invited_by: formData.currentUserId,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
delete requestData.password; // this field is not needed for the request
invitesAPI
.create(requestData)
.then(() => {
renderFlash(
"success",
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.`
);
toggleCreateUserModal();
refetchInvites();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setCreateUserErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setCreateUserErrors({
password: "Password must meet the criteria below",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsLoading(false);
});
} else {
// Do some data formatting deleting unnecessary fields
const requestData = {
...formData,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
usersAPI
.createUserWithoutInvitation(requestData)
.then(() => {
renderFlash("success", `Successfully created ${requestData.name}.`);
toggleCreateUserModal();
refetchUsers();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("Duplicate")) {
setCreateUserErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setCreateUserErrors({
password: "Password must meet the criteria below",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsLoading(false);
});
}
};
const onEditUser = (formData: any) => {
const userData = getUser(userEditing.type, userEditing.id);
let userUpdatedFlashMessage = `Successfully edited ${formData.name}`;
if (userData?.email !== formData.email) {
userUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}`;
}
const userUpdatedEmailError =
"A user with this email address already exists";
const userUpdatedPasswordError = "Password must meet the criteria below";
const userUpdatedError = `Could not edit ${userEditing?.name}. Please try again.`;
setIsEditingUser(true);
if (userEditing.type === "invite") {
return (
userData &&
invitesAPI
.update(userData.id, formData)
.then(() => {
renderFlash("success", userUpdatedFlashMessage);
toggleEditUserModal();
refetchInvites();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setEditUserErrors({
email: userUpdatedEmailError,
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setEditUserErrors({
password: userUpdatedPasswordError,
});
} else {
renderFlash("error", userUpdatedError);
}
})
.finally(() => {
setIsEditingUser(false);
})
);
}
return (
userData &&
usersAPI
.update(userData.id, formData)
.then(() => {
renderFlash("success", userUpdatedFlashMessage);
toggleEditUserModal();
refetchUsers();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setEditUserErrors({
email: userUpdatedEmailError,
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setEditUserErrors({
password: userUpdatedPasswordError,
});
} else {
renderFlash("error", userUpdatedError);
}
})
.finally(() => {
setIsEditingUser(false);
})
);
};
const onDeleteUser = () => {
if (userEditing.type === "invite") {
invitesAPI
.destroy(userEditing.id)
.then(() => {
renderFlash("success", `Successfully deleted ${userEditing?.name}.`);
})
.catch(() => {
renderFlash(
"error",
`Could not delete ${userEditing?.name}. Please try again.`
);
})
.finally(() => {
toggleDeleteUserModal();
refetchInvites();
});
} else {
usersAPI
.destroy(userEditing.id)
.then(() => {
renderFlash("success", `Successfully deleted ${userEditing?.name}.`);
})
.catch(() => {
renderFlash(
"error",
`Could not delete ${userEditing?.name}. Please try again.`
);
})
.finally(() => {
toggleDeleteUserModal();
refetchUsers();
});
}
};
const onResetSessions = () => {
const isResettingCurrentUser = currentUser?.id === userEditing.id;
usersAPI
.deleteSessions(userEditing.id)
.then(() => {
if (isResettingCurrentUser) {
clearToken();
setTimeout(() => {
window.location.href = "/";
}, 500);
return;
}
renderFlash("success", "Successfully reset sessions.");
})
.catch(() => {
renderFlash("error", "Could not reset sessions. Please try again.");
})
.finally(() => {
toggleResetSessionsUserModal();
});
};
const resetPassword = (user: IUser) => {
return usersAPI
.requirePasswordReset(user.id, { require: true })
.then(() => {
renderFlash("success", "Successfully required a password reset.");
})
.catch(() => {
renderFlash(
"error",
"Could not require a password reset. Please try again."
);
})
.finally(() => {
toggleResetPasswordUserModal();
});
};
const renderEditUserModal = () => {
const userData = getUser(userEditing.type, userEditing.id);
return (
<Modal title="Edit user" onExit={toggleEditUserModal}>
<>
<EditUserModal
defaultEmail={userData?.email}
defaultName={userData?.name}
defaultGlobalRole={userData?.global_role}
defaultTeams={userData?.teams}
onCancel={toggleEditUserModal}
onSubmit={onEditUser}
availableTeams={teams || []}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings.configured || false}
canUseSso={config?.sso_settings.enable_sso || false}
isSsoEnabled={userData?.sso_enabled}
isModifiedByGlobalAdmin
isInvitePending={userEditing.type === "invite"}
editUserErrors={editUserErrors}
isLoading={isEditingUser}
/>
</>
</Modal>
);
};
const renderCreateUserModal = () => {
return (
<CreateUserModal
createUserErrors={createUserErrors}
onCancel={toggleCreateUserModal}
onSubmit={onCreateUserSubmit}
availableTeams={teams || []}
defaultGlobalRole={"observer"}
defaultTeams={[]}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings.configured || false}
canUseSso={config?.sso_settings.enable_sso || false}
isLoading={isLoading}
isModifiedByGlobalAdmin
/>
);
};
const renderDeleteUserModal = () => {
return (
<Modal title={"Delete user"} onExit={toggleDeleteUserModal}>
<DeleteUserForm
name={userEditing.name}
onDelete={onDeleteUser}
onCancel={toggleDeleteUserModal}
/>
</Modal>
);
};
const renderResetPasswordModal = () => {
return (
<ResetPasswordModal
user={userEditing}
onResetConfirm={resetPassword}
onResetCancel={toggleResetPasswordUserModal}
/>
);
};
const renderResetSessionsModal = () => {
return (
<ResetSessionsModal
user={userEditing}
onResetConfirm={onResetSessions}
onResetCancel={toggleResetSessionsUserModal}
/>
);
};
const tableHeaders = generateTableHeaders(
onActionSelect,
isPremiumTier || false
);
const loadingTableData =
isFetchingUsers || isFetchingInvites || isFetchingTeams;
const tableDataError =
loadingUsersError || loadingInvitesError || loadingTeamsError;
let tableData: unknown = [];
if (!loadingTableData && !tableDataError) {
tableData = combineUsersAndInvites(users, invites, currentUser?.id);
}
return (
<>
{/* TODO: find a way to move these controls into the table component */}
{tableDataError ? (
<TableDataError />
) : (
<TableContainer
columns={tableHeaders}
data={tableData}
isLoading={loadingTableData}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Search"}
actionButtonText={"Create user"}
onActionButtonClick={toggleCreateUserModal}
onQueryChange={onTableQueryChange}
resultsTitle={"users"}
emptyComponent={EmptyUsers}
searchable
showMarkAllPages={false}
isAllPagesSelected={false}
isClientSidePagination
/>
)}
{showCreateUserModal && renderCreateUserModal()}
{showEditUserModal && renderEditUserModal()}
{showDeleteUserModal && renderDeleteUserModal()}
{showResetSessionsModal && renderResetSessionsModal()}
{showResetPasswordModal && renderResetPasswordModal()}
</>
);
};
export default UsersTable;

View File

@ -7,7 +7,7 @@ import { IInvite } from "interfaces/invite";
import { IUser } from "interfaces/user";
import { IDropdownOption } from "interfaces/dropdownOption";
import { generateRole, generateTeam, greyCell } from "utilities/helpers";
import DropdownCell from "../../../components/TableContainer/DataTable/DropdownCell";
import DropdownCell from "../../../../../components/TableContainer/DataTable/DropdownCell";
interface IHeaderProps {
column: {

View File

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

View File

@ -137,6 +137,7 @@ const ManageHostsPage = ({
isOnlyObserver,
isPremiumTier,
isFreeTier,
isSandboxMode,
setCurrentTeam,
} = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
@ -1598,15 +1599,18 @@ const ManageHostsPage = ({
<div className="header-wrap">
{renderHeader()}
<div className={`${baseClass} button-wrap`}>
{canEnrollHosts && !hasHostErrors && !hasHostCountErrors && (
<Button
onClick={() => setShowEnrollSecretModal(true)}
className={`${baseClass}__enroll-hosts button`}
variant="inverse"
>
<span>Manage enroll secret</span>
</Button>
)}
{!isSandboxMode &&
canEnrollHosts &&
!hasHostErrors &&
!hasHostCountErrors && (
<Button
onClick={() => setShowEnrollSecretModal(true)}
className={`${baseClass}__enroll-hosts button`}
variant="inverse"
>
<span>Manage enroll secret</span>
</Button>
)}
{canEnrollHosts &&
!hasHostErrors &&
!hasHostCountErrors &&