mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
Add SandboxGate to fleet UI that gates functionality when in sandbox mode (#6738)
This commit is contained in:
parent
7afef3f035
commit
f4b20b6ae5
1
changes/issue-6544-gating-features-in-sandbox-mode
Normal file
1
changes/issue-6544-gating-features-in-sandbox-mode
Normal file
@ -0,0 +1 @@
|
||||
- Add gating of features on org settings, users mangament, user settings, and manage hosts pages
|
@ -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");
|
||||
});
|
||||
|
@ -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")
|
||||
|
69
frontend/components/SandboxGate/SandboxGate.tsx
Normal file
69
frontend/components/SandboxGate/SandboxGate.tsx
Normal 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;
|
27
frontend/components/SandboxGate/_styles.scss
Normal file
27
frontend/components/SandboxGate/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
1
frontend/components/SandboxGate/index.ts
Normal file
1
frontend/components/SandboxGate/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./SandboxGate";
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./OrgSettingsForm";
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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;
|
@ -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: {
|
@ -0,0 +1 @@
|
||||
export { default } from "./UsersTable";
|
@ -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 &&
|
||||
|
Loading…
Reference in New Issue
Block a user