fleet/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx
Lucas Manuel Rodriguez 6c5d75e2e0
Fix conditional access deletion (#33481)
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
2025-09-26 13:02:52 -03:00

418 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;