mirror of
https://github.com/fleetdm/fleet
synced 2026-05-06 06:48:54 +00:00
Resolves #32419. I took a stab at it while fixing #32420. Sorry, missed to record with audio: - I test with the proxy being down (to simulate failure when deleting) and that the delete modal is not closed. - Spinner during the delete API request. - Cancel button disabled during the delete API request/. - Tenant ID is cleared after successful deletion. https://github.com/user-attachments/assets/dbad0613-a8bd-455d-8741-83c626328437 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [X] QA'd all new/changed functionality manually
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
import React, { useContext, useEffect, useState } from "react";
|
||
|
||
import { size } from "lodash";
|
||
|
||
import paths from "router/paths";
|
||
|
||
import { NotificationContext } from "context/notification";
|
||
|
||
import conditionalAccessAPI, {
|
||
ConfirmMSConditionalAccessResponse,
|
||
} from "services/entities/conditional_access";
|
||
import configAPI from "services/entities/config";
|
||
|
||
// @ts-ignore
|
||
import InputField from "components/forms/fields/InputField";
|
||
import CustomLink from "components/CustomLink";
|
||
import SectionHeader from "components/SectionHeader";
|
||
|
||
import {
|
||
DEFAULT_USE_QUERY_OPTIONS,
|
||
LEARN_MORE_ABOUT_BASE_LINK,
|
||
} from "utilities/constants";
|
||
import Button from "components/buttons/Button";
|
||
import { IInputFieldParseTarget } from "interfaces/form_field";
|
||
import { AppContext } from "context/app";
|
||
import Spinner from "components/Spinner";
|
||
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
||
import InfoBanner from "components/InfoBanner";
|
||
import Icon from "components/Icon";
|
||
import TooltipTruncatedText from "components/TooltipTruncatedText";
|
||
import { useQuery } from "react-query";
|
||
import DataError from "components/DataError";
|
||
import Modal from "components/Modal";
|
||
import { IConfig } from "interfaces/config";
|
||
|
||
const baseClass = "conditional-access";
|
||
|
||
const MSETID = "microsoft_entra_tenant_id";
|
||
|
||
interface IDeleteConditionalAccessModal {
|
||
toggleDeleteConditionalAccessModal: () => void;
|
||
onDelete: () => void;
|
||
}
|
||
|
||
const DeleteConditionalAccessModal = ({
|
||
toggleDeleteConditionalAccessModal,
|
||
onDelete,
|
||
}: IDeleteConditionalAccessModal) => {
|
||
const { renderFlash } = useContext(NotificationContext);
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
|
||
const handleDelete = async () => {
|
||
setIsDeleting(true);
|
||
try {
|
||
await conditionalAccessAPI.deleteMicrosoftConditionalAccess();
|
||
renderFlash("success", "Successfully disconnected from Microsoft Entra.");
|
||
toggleDeleteConditionalAccessModal();
|
||
onDelete();
|
||
} catch {
|
||
renderFlash(
|
||
"error",
|
||
"Could not disconnect from Microsoft Entra, please try again."
|
||
);
|
||
}
|
||
setIsDeleting(false);
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
title="Delete"
|
||
onExit={toggleDeleteConditionalAccessModal}
|
||
onEnter={onDelete}
|
||
>
|
||
<>
|
||
<p>
|
||
Fleet will be disconnected from Microsoft Entra and will stop blocking
|
||
end users from logging in with single sign-on.
|
||
</p>
|
||
<div className="modal-cta-wrap">
|
||
<Button
|
||
type="button"
|
||
variant="alert"
|
||
onClick={handleDelete}
|
||
isLoading={isDeleting}
|
||
disabled={isDeleting}
|
||
>
|
||
Delete
|
||
</Button>
|
||
<Button
|
||
onClick={toggleDeleteConditionalAccessModal}
|
||
variant="inverse-alert"
|
||
disabled={isDeleting}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
// conditions –> UI phases:
|
||
// - no config.tenant id –> "form"
|
||
// - config.tenant id:
|
||
// - and config.confirmed –> "configured"
|
||
// - not config.confirmed –> "confirming-configured", hit confirmation endpoint
|
||
// - confirmation endpoint returns false –> "form", prefilled with current tid
|
||
// - confirmation endpoint returns true –> "configured"
|
||
// - conf ep returns error –> DataError, under header
|
||
// - form submitted –> "form-submitted", new tab to MS stuff
|
||
//
|
||
|
||
interface IFormData {
|
||
[MSETID]: string;
|
||
}
|
||
|
||
interface IFormErrors {
|
||
[MSETID]?: string | null;
|
||
}
|
||
|
||
enum Phase {
|
||
Form = "form",
|
||
FormSubmitted = "form-submitted",
|
||
ConfirmingConfigured = "confirming-configured",
|
||
ConfirmationError = "confirmation-error",
|
||
Configured = "configured",
|
||
}
|
||
|
||
const validate = (formData: IFormData) => {
|
||
const errs: IFormErrors = {};
|
||
if (!formData[MSETID]) {
|
||
errs[MSETID] = "Tenant ID must be present";
|
||
}
|
||
return errs;
|
||
};
|
||
|
||
const ConditionalAccess = () => {
|
||
// HOOKS
|
||
const { renderFlash } = useContext(NotificationContext);
|
||
|
||
const { isPremiumTier, setConfig, config: contextConfig } = useContext(
|
||
AppContext
|
||
);
|
||
|
||
const [phase, setPhase] = useState<Phase>(Phase.Form);
|
||
const [isUpdating, setIsUpdating] = useState(false);
|
||
|
||
// this page is unique in that it triggers a server process that will result in an update to
|
||
// config, but via an endpoint (conditional access) other than the usual PATCH config, so we want
|
||
// to both reference config context AND conditionally (when `isUpdating` from the Configured
|
||
// phase) access `refetchConfig` and associated useQuery capability
|
||
|
||
// see frontend/docs/patterns.md > ### Reading and updating configs for why this is atypical
|
||
|
||
const { refetch: refetchConfig } = useQuery<IConfig, Error, IConfig>(
|
||
["config"],
|
||
() => configAPI.loadAll(),
|
||
{
|
||
select: (data: IConfig) => data,
|
||
enabled: isUpdating && phase === Phase.Configured,
|
||
onSuccess: (_config) => {
|
||
if (
|
||
!_config?.conditional_access?.microsoft_entra_connection_configured
|
||
) {
|
||
setPhase(Phase.Form);
|
||
}
|
||
setConfig(_config);
|
||
setIsUpdating(false);
|
||
},
|
||
...DEFAULT_USE_QUERY_OPTIONS,
|
||
}
|
||
);
|
||
|
||
const [formData, setFormData] = useState<IFormData>({
|
||
[MSETID]:
|
||
contextConfig?.conditional_access?.microsoft_entra_tenant_id || "",
|
||
});
|
||
const [formErrors, setFormErrors] = useState<IFormErrors>({});
|
||
const [
|
||
showDeleteConditionalAccessModal,
|
||
setShowDeleteConditionalAccessModal,
|
||
] = useState(false);
|
||
|
||
// "loading" state here is encompassed by phase === Phase.ConfirmingConfigured state, don't need
|
||
// to use useQuery's
|
||
// "error" state handled by onError callback
|
||
// success state handled by onSuccess callback
|
||
useQuery<
|
||
ConfirmMSConditionalAccessResponse,
|
||
Error,
|
||
ConfirmMSConditionalAccessResponse
|
||
>(["confirmAccess"], conditionalAccessAPI.confirmMicrosoftConditionalAccess, {
|
||
...DEFAULT_USE_QUERY_OPTIONS,
|
||
// only make this call at the appropriate UI phase
|
||
enabled: phase === Phase.ConfirmingConfigured && isPremiumTier,
|
||
onSuccess: ({ configuration_completed, setup_error }) => {
|
||
if (configuration_completed) {
|
||
setPhase(Phase.Configured);
|
||
renderFlash(
|
||
"success",
|
||
"Successfully verified conditional access integration"
|
||
);
|
||
} else {
|
||
setPhase(Phase.Form);
|
||
|
||
if (
|
||
// IT admin did not complete the consent.
|
||
!setup_error ||
|
||
// IT admin clicked "Cancel" in the consent dialog.
|
||
setup_error.includes(
|
||
"A Microsoft Entra admin did not consent to the permissions requested by the conditional access integration"
|
||
)
|
||
) {
|
||
renderFlash(
|
||
"error",
|
||
"Couldn't update. Fleet didn't get permissions for Entra. Please try again and accept the permissions."
|
||
);
|
||
} else if (
|
||
setup_error.includes(
|
||
'No "Fleet conditional access" Entra ID group was found'
|
||
)
|
||
) {
|
||
renderFlash(
|
||
"error",
|
||
`Couldn't connect. The "Fleet conditional access" group doesn't exist in Entra. Please create the group and try again.`
|
||
);
|
||
} else {
|
||
// For other kind of errors we just show a generic error.
|
||
// We won't render the error as is because the error comes from the MS proxy and they may be too big or unformatted
|
||
// to display in the banner.
|
||
//
|
||
// For troubleshooting:
|
||
// - The API response contains the setup_error.
|
||
// - The Fleet server logs the error.
|
||
// - The MS proxy stores the error in its database.
|
||
renderFlash(
|
||
"error",
|
||
"Couldn't connect. Please contact your Fleet administrator."
|
||
);
|
||
}
|
||
}
|
||
},
|
||
onError: () => {
|
||
// distinct from successful confirmation response of `false`, this handles an API error
|
||
setPhase(Phase.ConfirmationError);
|
||
},
|
||
});
|
||
|
||
const {
|
||
microsoft_entra_tenant_id: contextConfigMsetId,
|
||
microsoft_entra_connection_configured: contextConfigMseConfigured,
|
||
} = contextConfig?.conditional_access || {};
|
||
|
||
// only checks if tenant id already present in config, not if user added it to the form
|
||
useEffect(() => {
|
||
if (contextConfigMsetId) {
|
||
if (!contextConfigMseConfigured) {
|
||
setPhase(Phase.ConfirmingConfigured);
|
||
} else {
|
||
// tenant id is present and connection is configured
|
||
setPhase(Phase.Configured);
|
||
}
|
||
}
|
||
}, [contextConfigMsetId, contextConfigMseConfigured]);
|
||
|
||
if (!isPremiumTier) {
|
||
return <PremiumFeatureMessage />;
|
||
}
|
||
|
||
// HANDLERS
|
||
|
||
const toggleDeleteConditionalAccessModal = () => {
|
||
setShowDeleteConditionalAccessModal(!showDeleteConditionalAccessModal);
|
||
};
|
||
|
||
const onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||
evt.preventDefault();
|
||
|
||
const errs = validate(formData);
|
||
if (Object.keys(errs).length > 0) {
|
||
setFormErrors(errs);
|
||
return;
|
||
}
|
||
setIsUpdating(true);
|
||
try {
|
||
const {
|
||
microsoft_authentication_url: msAuthURL,
|
||
} = await conditionalAccessAPI.triggerMicrosoftConditionalAccess(
|
||
formData[MSETID]
|
||
);
|
||
setIsUpdating(false);
|
||
setPhase(Phase.FormSubmitted);
|
||
window.open(msAuthURL);
|
||
} catch (e) {
|
||
renderFlash(
|
||
"error",
|
||
"Could not update conditional access integration settings."
|
||
);
|
||
setIsUpdating(false);
|
||
}
|
||
};
|
||
|
||
const onDeleteConditionalAccess = async () => {
|
||
setFormData({ [MSETID]: "" });
|
||
refetchConfig();
|
||
};
|
||
|
||
const onInputChange = ({ name, value }: IInputFieldParseTarget) => {
|
||
const newFormData = { ...formData, [name]: value };
|
||
setFormData(newFormData);
|
||
const newErrs = validate(newFormData);
|
||
// only set errors that are updates of existing errors
|
||
// new errors are only set onBlur or submit
|
||
const errsToSet: Record<string, string> = {};
|
||
Object.keys(formErrors).forEach((k) => {
|
||
// @ts-ignore
|
||
if (newErrs[k]) {
|
||
// @ts-ignore
|
||
errsToSet[k] = newErrs[k];
|
||
}
|
||
});
|
||
setFormErrors(errsToSet);
|
||
};
|
||
|
||
const onInputBlur = () => {
|
||
setFormErrors(validate(formData));
|
||
};
|
||
|
||
const renderContent = () => {
|
||
switch (phase) {
|
||
case Phase.Form:
|
||
return (
|
||
<form onSubmit={onSubmit} autoComplete="off">
|
||
<InputField
|
||
label="Microsoft Entra tenant ID"
|
||
helpText={
|
||
<>
|
||
You can find this in your Microsoft Entra admin center.{" "}
|
||
<CustomLink
|
||
url={`${LEARN_MORE_ABOUT_BASE_LINK}/microsoft-entra-setup`}
|
||
text="Learn more"
|
||
newTab
|
||
/>
|
||
</>
|
||
}
|
||
onChange={onInputChange}
|
||
name={MSETID}
|
||
value={formData[MSETID]}
|
||
parseTarget
|
||
onBlur={onInputBlur}
|
||
error={formErrors[MSETID]}
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
disabled={!!size(formErrors)}
|
||
className="button-wrap"
|
||
isLoading={isUpdating}
|
||
>
|
||
Save
|
||
</Button>
|
||
</form>
|
||
);
|
||
case Phase.FormSubmitted:
|
||
return (
|
||
<InfoBanner>
|
||
To complete your integration, follow the instructions in the other
|
||
tab, then refresh this page to verify.
|
||
</InfoBanner>
|
||
);
|
||
case Phase.ConfirmingConfigured:
|
||
// checking integration
|
||
return <Spinner />;
|
||
case Phase.ConfirmationError:
|
||
return <DataError />;
|
||
case Phase.Configured:
|
||
return (
|
||
<InfoBanner color="grey" className={`${baseClass}__success`}>
|
||
<div className="tenant-id">
|
||
<Icon name="success" />
|
||
<b>Microsoft Entra tenant ID:</b>{" "}
|
||
<TooltipTruncatedText value={formData[MSETID]} />
|
||
</div>
|
||
<Button
|
||
variant="text-icon"
|
||
onClick={toggleDeleteConditionalAccessModal}
|
||
>
|
||
Delete
|
||
<Icon name="trash" />
|
||
</Button>
|
||
</InfoBanner>
|
||
);
|
||
default:
|
||
return <Spinner />;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={baseClass}>
|
||
<SectionHeader title="Conditional access" />
|
||
<p className={`${baseClass}__page-description`}>
|
||
Block hosts failing any policies from logging in with single sign-on.
|
||
Enable or disable on the{" "}
|
||
<CustomLink url={paths.MANAGE_POLICIES} text="Policies" /> page.
|
||
</p>
|
||
{renderContent()}
|
||
{showDeleteConditionalAccessModal && (
|
||
<DeleteConditionalAccessModal
|
||
onDelete={onDeleteConditionalAccess}
|
||
toggleDeleteConditionalAccessModal={
|
||
toggleDeleteConditionalAccessModal
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ConditionalAccess;
|