Updating UI for Okta config (#35204)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34539

Figma:
https://www.figma.com/design/OgQ8SyLK8Sw5thXtF1eiNP/-31909-Conditional-access-w--Okta

Requires backend PR https://github.com/fleetdm/fleet/pull/35526 to view
Apple profile.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added Okta as a conditional access provider alongside Microsoft Entra
* Users can now configure both identity providers simultaneously or use
either independently
  * Updated configuration interface with new Okta-specific settings
  * Redesigned UI with separate provider cards for improved clarity

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Jacob Shandling <jacob@shandling.dev>
This commit is contained in:
Victor Lyuboslavsky
2025-11-18 19:34:59 -06:00
committed by GitHub
co-authored by Jacob Shandling
parent 8a25886781
commit 767c594ad8
17 changed files with 1392 additions and 255 deletions
+1
View File
@@ -46,6 +46,7 @@ const config: StorybookConfig = {
"../frontend/components/**/*.stories.mdx",
"../frontend/components/**/*.stories.@(js|jsx|ts|tsx)",
"../frontend/pages/SoftwarePage/components/**/*.stories.@(js|jsx|ts|tsx)",
"../frontend/pages/admin/IntegrationsPage/**/*.stories.@(js|jsx|ts|tsx)",
],
addons: [
"@storybook/addon-links",
+2 -1
View File
@@ -1 +1,2 @@
Added integration for Okta conditional access, where Fleet acts as a factor and blocks end users from logging into third-party apps, via Okta, if they are failing specific policies.
- Added integration for Okta conditional access, where Fleet acts as a factor and blocks end users from logging into third-party apps, via Okta, if they are failing specific policies.
- Added Okta conditional access configuration to the Fleet UI under Settings -> Integrations -> Conditional access.
+4
View File
@@ -115,6 +115,10 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
conditional_access: {
microsoft_entra_tenant_id: "123",
microsoft_entra_connection_configured: true,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
},
host_expiry_settings: {
host_expiry_enabled: false,
+6
View File
@@ -156,8 +156,14 @@ export interface IConfig {
// configuration details for conditional access. For enabled/disabled status per team, see
// subfields under `integrations`
conditional_access?: {
// Microsoft Entra
microsoft_entra_tenant_id: string;
microsoft_entra_connection_configured: boolean;
// Okta
okta_idp_id: string;
okta_assertion_consumer_service_url: string;
okta_audience_uri: string;
okta_certificate: string;
};
host_expiry_settings: {
host_expiry_enabled: boolean;
@@ -11,9 +11,7 @@ import IdentityProviders from "./cards/IdentityProviders";
import Sso from "./cards/Sso";
import GlobalHostStatusWebhook from "../IntegrationsPage/cards/GlobalHostStatusWebhook";
const getIntegrationSettingsNavItems = (
isManagedCloud: boolean
): ISideNavItem<any>[] => {
const getIntegrationSettingsNavItems = (): ISideNavItem<any>[] => {
const items: ISideNavItem<any>[] = [
{
title: "Ticket destinations",
@@ -63,16 +61,14 @@ const getIntegrationSettingsNavItems = (
path: PATHS.ADMIN_INTEGRATIONS_HOST_STATUS_WEBHOOK,
Card: GlobalHostStatusWebhook,
},
];
if (isManagedCloud) {
items.push({
{
title: "Conditional access",
urlSection: "conditional-access",
path: PATHS.ADMIN_INTEGRATIONS_CONDITIONAL_ACCESS,
Card: ConditionalAccess,
});
}
},
];
return items;
};
@@ -7,8 +7,6 @@ import deepDifference from "utilities/deep_difference";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import paths from "router/paths";
import configAPI from "services/entities/config";
import { IConfig } from "interfaces/config";
@@ -90,12 +88,8 @@ const IntegrationsPage = ({
);
if (!appConfig) return <></>;
const isManagedCloud = appConfig.license.managed_cloud;
if (section?.includes("conditional-access") && !isManagedCloud) {
router.push(paths.ADMIN_SETTINGS);
}
const navItems = getIntegrationSettingsNavItems(isManagedCloud);
const navItems = getIntegrationSettingsNavItems();
const DEFAULT_SETTINGS_SECTION = navItems[0];
const currentSection =
navItems.find((item) => item.urlSection === section) ??
@@ -1,7 +1,40 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { Meta, StoryObj } from "@storybook/react";
import {
QueryClient,
QueryClientProvider,
QueryClientProviderProps,
} from "react-query";
import createMockConfig from "__mocks__/configMock";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import ConditionalAccess from "./ConditionalAccess";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Workaround for React Query v3 with React 18 - explicitly add children prop
// See frontend/router/index.tsx for the same pattern
type CustomQueryClientProviderProps = React.PropsWithChildren<QueryClientProviderProps>;
const CustomQueryClientProvider: React.FC<CustomQueryClientProviderProps> = QueryClientProvider;
const mockNotificationContext = {
renderFlash: () => {
// Mock function for stories
},
hideFlash: () => {
// Mock function for stories
},
};
const meta: Meta<typeof ConditionalAccess> = {
title: "Components/ConditionalAccess",
component: ConditionalAccess,
@@ -11,4 +44,177 @@ export default meta;
type Story = StoryObj<typeof ConditionalAccess>;
export const Basic: Story = {};
export const NotConfigured: Story = {
name: "Not configured (premium tier)",
decorators: [
(Story) => {
const appContextValue = {
isPremiumTier: true,
config: createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
},
}),
setConfig: () => {
// Mock function for stories
},
};
return (
<CustomQueryClientProvider client={queryClient}>
<AppContext.Provider value={appContextValue as any}>
<NotificationContext.Provider
value={mockNotificationContext as any}
>
<Story />
</NotificationContext.Provider>
</AppContext.Provider>
</CustomQueryClientProvider>
);
},
],
};
export const EntraConfigured: Story = {
name: "Microsoft Entra configured",
decorators: [
(Story) => {
const appContextValue = {
isPremiumTier: true,
config: createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcd-1234-efgh-5678",
microsoft_entra_connection_configured: true,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
},
}),
setConfig: () => {
// Mock function for stories
},
};
return (
<CustomQueryClientProvider client={queryClient}>
<AppContext.Provider value={appContextValue as any}>
<NotificationContext.Provider
value={mockNotificationContext as any}
>
<Story />
</NotificationContext.Provider>
</AppContext.Provider>
</CustomQueryClientProvider>
);
},
],
};
export const OktaConfigured: Story = {
name: "Okta configured",
decorators: [
(Story) => {
const appContextValue = {
isPremiumTier: true,
config: createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "okta-idp-123456",
okta_assertion_consumer_service_url:
"https://fleet.example.com/api/v1/saml/acs",
okta_audience_uri: "https://fleet.example.com",
okta_certificate:
"-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
},
}),
setConfig: () => {
// Mock function for stories
},
};
return (
<CustomQueryClientProvider client={queryClient}>
<AppContext.Provider value={appContextValue as any}>
<NotificationContext.Provider
value={mockNotificationContext as any}
>
<Story />
</NotificationContext.Provider>
</AppContext.Provider>
</CustomQueryClientProvider>
);
},
],
};
export const BothConfigured: Story = {
name: "Both providers configured",
decorators: [
(Story) => {
const appContextValue = {
isPremiumTier: true,
config: createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcd-1234-efgh-5678",
microsoft_entra_connection_configured: true,
okta_idp_id: "okta-idp-123456",
okta_assertion_consumer_service_url:
"https://fleet.example.com/api/v1/saml/acs",
okta_audience_uri: "https://fleet.example.com",
okta_certificate:
"-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
},
}),
setConfig: () => {
// Mock function for stories
},
};
return (
<CustomQueryClientProvider client={queryClient}>
<AppContext.Provider value={appContextValue as any}>
<NotificationContext.Provider
value={mockNotificationContext as any}
>
<Story />
</NotificationContext.Provider>
</AppContext.Provider>
</CustomQueryClientProvider>
);
},
],
};
export const FreeTier: Story = {
name: "Free tier (premium feature)",
decorators: [
(Story) => {
const appContextValue = {
isPremiumTier: false,
config: createMockConfig({}),
setConfig: () => {
// Mock function for stories
},
};
return (
<CustomQueryClientProvider client={queryClient}>
<AppContext.Provider value={appContextValue as any}>
<NotificationContext.Provider
value={mockNotificationContext as any}
>
<Story />
</NotificationContext.Provider>
</AppContext.Provider>
</CustomQueryClientProvider>
);
},
],
};
@@ -1,6 +1,6 @@
import React from "react";
import { screen } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import createMockConfig from "__mocks__/configMock";
@@ -18,25 +18,128 @@ const triggerConditionalAccessHandler = http.post(
}
);
const updateConfigHandler = http.patch(baseUrl("/config"), () => {
return HttpResponse.json(
createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "okta-idp-123",
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
},
})
);
});
// Helper to create a config with empty conditional access settings
const createEmptyConditionalAccessConfig = () =>
createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
},
});
describe("Conditional access", () => {
describe("Not configured", () => {
it("Renders the empty form when no tenant id is saved", () => {
it("Renders both integration cards when nothing is configured", () => {
const mockConfig = createEmptyConditionalAccessConfig();
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
config: mockConfig,
},
},
});
render(<ConditionalAccess />);
expect(screen.getByText("Microsoft Entra tenant ID")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toHaveValue("");
expect(screen.getByText("Okta")).toBeInTheDocument();
expect(
screen.getByText("Connect Okta to enable conditional access.")
).toBeInTheDocument();
expect(screen.getByText("Microsoft Entra")).toBeInTheDocument();
expect(
screen.getByText("Connect Entra to enable conditional access.")
).toBeInTheDocument();
// Should have two Connect buttons
expect(screen.getAllByText("Connect")).toHaveLength(2);
});
it("Renders the 'continue in new tab' screen when the form is submitted", async () => {
it("Opens the Entra modal when clicking Connect on Entra card", async () => {
const mockConfig = createEmptyConditionalAccessConfig();
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
config: mockConfig,
},
},
});
const { user } = render(<ConditionalAccess />);
// Click the second Connect button (Microsoft Entra)
const connectButtons = screen.getAllByText("Connect");
await user.click(connectButtons[1]);
// Modal should open
expect(
screen.getByText("Microsoft Entra conditional access")
).toBeInTheDocument();
expect(screen.getByText("Microsoft Entra tenant ID")).toBeInTheDocument();
});
it("Triggers Microsoft auth flow when submitting Entra modal", async () => {
const mockConfig = createEmptyConditionalAccessConfig();
mockServer.use(triggerConditionalAccessHandler);
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
config: mockConfig,
},
},
});
const { user } = render(<ConditionalAccess />);
// Open modal
const connectButtons = screen.getAllByText("Connect");
await user.click(connectButtons[1]);
// Fill in tenant ID
const input = screen.getByRole("textbox");
await user.type(input, "abcdefg");
// Submit form
const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton);
// Should show the "continue in new tab" message
await waitFor(() => {
expect(
screen.getByText(
/To complete your integration, follow the instructions in the other tab/
)
).toBeInTheDocument();
});
});
it("Opens the Okta modal when clicking Connect on Okta card", async () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
@@ -48,23 +151,102 @@ describe("Conditional access", () => {
const { user } = render(<ConditionalAccess />);
const input = screen.getByRole("textbox");
await user.type(input, "abcdefg");
await user.click(screen.getByRole("button"));
// Click the first Connect button (Okta)
const connectButtons = screen.getAllByText("Connect");
await user.click(connectButtons[0]);
// Modal should open with new Figma structure
expect(screen.getByText("Okta conditional access")).toBeInTheDocument();
// Check for new sections
expect(
screen.getByText(
"To complete your integration, follow the instructions in the other tab, then refresh this page to verify."
)
screen.getByText("Identity provider (IdP) signature certificate")
).toBeInTheDocument();
expect(screen.getByText("User scope profile")).toBeInTheDocument();
// Check for input fields
expect(screen.getByText("IdP ID")).toBeInTheDocument();
expect(
screen.getByText("Assertion consumer service URL")
).toBeInTheDocument();
expect(screen.getByText("Audience URI")).toBeInTheDocument();
// Check for certificate upload section
expect(screen.getByText("Okta certificate")).toBeInTheDocument();
});
it("Saves Okta configuration when submitting form", async () => {
const mockConfig = createEmptyConditionalAccessConfig();
mockServer.use(updateConfigHandler);
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
config: mockConfig,
},
},
});
const { user } = render(<ConditionalAccess />);
// Open modal
const connectButtons = screen.getAllByText("Connect");
await user.click(connectButtons[0]);
// Wait for modal to open
await waitFor(() => {
expect(screen.getByText("Okta conditional access")).toBeInTheDocument();
});
// Fill in text fields
// Note: First textarea is the read-only User scope profile
const textboxes = screen.getAllByRole("textbox");
await user.type(textboxes[1], "okta-idp-123"); // IdP ID
await user.type(textboxes[2], "https://example.com/acs"); // ACS URL
await user.type(textboxes[3], "https://example.com"); // Audience URI
// Upload certificate file
const certificateContent = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRKm7MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
-----END CERTIFICATE-----`;
const file = new File([certificateContent], "certificate.pem", {
type: "application/x-pem-file",
});
const fileInput = document.querySelector(
'input[type="file"]'
) as HTMLInputElement;
await user.upload(fileInput, file);
// Wait for file to be processed
await waitFor(() => {
expect(screen.getByText("certificate.pem")).toBeInTheDocument();
});
// Submit form
const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton);
// Should show success message and close modal
await waitFor(() => {
expect(
screen.queryByText("Okta conditional access")
).not.toBeInTheDocument();
});
});
});
describe("Confirming configured", () => {
it("Renders a spinner when tenant id is present but configuation not yet confirmed", () => {
it("Renders a spinner when Entra tenant id is present but configuration not yet confirmed", () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcdefg",
microsoft_entra_connection_configured: false,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
},
});
@@ -83,12 +265,17 @@ describe("Conditional access", () => {
expect(screen.getByTestId("spinner")).toBeVisible();
});
});
describe("Configured", () => {
it("Renders the 'configured' screen when tenant id is present and configuration is confirmed", async () => {
it("Shows Entra as configured when connection is confirmed", async () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcdefg",
microsoft_entra_connection_configured: true,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
},
});
@@ -105,9 +292,140 @@ describe("Conditional access", () => {
render(<ConditionalAccess />);
expect(
screen.getByText("Microsoft Entra tenant ID:")
screen.getByText("Microsoft Entra conditional access configured")
).toBeInTheDocument();
// Should only have Delete button for Entra (no Edit button per Figma design)
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.queryByText("Edit")).not.toBeInTheDocument();
});
it("Shows Okta as configured when all Okta fields are present", async () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "okta-idp-123",
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
},
});
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
config: mockConfig,
},
},
});
render(<ConditionalAccess />);
expect(
screen.getByText("Okta conditional access configured")
).toBeInTheDocument();
});
it("Shows both providers as configured when both are set up", async () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcdefg",
microsoft_entra_connection_configured: true,
okta_idp_id: "okta-idp-123",
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
},
});
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
config: mockConfig,
},
},
});
render(<ConditionalAccess />);
expect(
screen.getByText("Okta conditional access configured")
).toBeInTheDocument();
expect(
screen.getByText("Microsoft Entra conditional access configured")
).toBeInTheDocument();
});
it("Shows delete confirmation modal when clicking Delete on Okta", async () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "okta-idp-123",
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
},
});
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: true,
config: mockConfig,
},
},
});
const { user } = render(<ConditionalAccess />);
// Should show configured state
expect(
screen.getByText("Okta conditional access configured")
).toBeInTheDocument();
// Click Delete button (first one is for Okta)
const deleteButton = screen.getAllByText("Delete")[0];
await user.click(deleteButton);
// Should show delete confirmation modal
await waitFor(() => {
expect(
screen.getByText(/Fleet will be disconnected from Okta/)
).toBeInTheDocument();
});
// Modal should have Delete and Cancel buttons
expect(
screen.getAllByRole("button", { name: "Delete" }).length
).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Cancel" })
).toBeInTheDocument();
});
});
describe("Premium tier", () => {
it("Shows premium feature message when not premium tier", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isPremiumTier: false,
},
},
});
render(<ConditionalAccess />);
expect(
screen.getByText(/This feature is included in Fleet Premium/i)
).toBeInTheDocument();
});
});
});
@@ -1,7 +1,5 @@
import React, { useContext, useEffect, useState } from "react";
import { size } from "lodash";
import paths from "router/paths";
import { NotificationContext } from "context/notification";
@@ -11,66 +9,87 @@ import conditionalAccessAPI, {
} 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 Icon from "components/Icon";
import { IconNames } from "components/icons";
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";
import SectionCard from "../MdmSettings/components/SectionCard";
import EntraConditionalAccessModal from "./components/EntraConditionalAccessModal";
import OktaConditionalAccessModal from "./components/OktaConditionalAccessModal";
const MSETID = "microsoft_entra_tenant_id";
const baseClass = "conditional-access";
interface IDeleteConditionalAccessModal {
toggleDeleteConditionalAccessModal: () => void;
onDelete: () => void;
onDelete: (config: IConfig) => void;
provider: "microsoft-entra" | "okta";
config: IConfig | null;
}
const DeleteConditionalAccessModal = ({
toggleDeleteConditionalAccessModal,
onDelete,
provider,
config,
}: IDeleteConditionalAccessModal) => {
const { renderFlash } = useContext(NotificationContext);
const [isDeleting, setIsDeleting] = useState(false);
const providerName =
provider === "microsoft-entra" ? "Microsoft Entra" : "Okta";
const handleDelete = async () => {
setIsDeleting(true);
try {
await conditionalAccessAPI.deleteMicrosoftConditionalAccess();
renderFlash("success", "Successfully disconnected from Microsoft Entra.");
let updatedConfig;
if (provider === "microsoft-entra") {
await conditionalAccessAPI.deleteMicrosoftConditionalAccess();
updatedConfig = await configAPI.loadAll();
} else {
// For Okta, clear all fields via config API
updatedConfig = await configAPI.update({
conditional_access: {
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
// Preserve existing Microsoft Entra settings
microsoft_entra_tenant_id:
config?.conditional_access?.microsoft_entra_tenant_id || "",
microsoft_entra_connection_configured:
config?.conditional_access
?.microsoft_entra_connection_configured || false,
},
});
}
renderFlash("success", `Successfully disconnected from ${providerName}.`);
toggleDeleteConditionalAccessModal();
onDelete();
onDelete(updatedConfig);
} catch {
renderFlash(
"error",
"Could not disconnect from Microsoft Entra, please try again."
`Could not disconnect from ${providerName}, please try again.`
);
}
setIsDeleting(false);
};
return (
<Modal
title="Delete"
onExit={toggleDeleteConditionalAccessModal}
onEnter={onDelete}
>
const copy =
provider === "microsoft-entra" ? (
<>
<p>
Before you delete, first unblock all end users.{" "}
@@ -84,6 +103,22 @@ const DeleteConditionalAccessModal = ({
If you don&apos;t, end users will stay blocked even after deleting
Entra.
</p>
</>
) : (
<p>
Fleet will be disconnected from Okta and will stop blocking end users
from logging in with single sign-on.
</p>
);
return (
<Modal
title="Delete"
onExit={toggleDeleteConditionalAccessModal}
onEnter={handleDelete}
>
<>
{copy}
<div className="modal-cta-wrap">
<Button
type="button"
@@ -107,87 +142,30 @@ const DeleteConditionalAccessModal = ({
);
};
// 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",
enum EntraPhase {
NotConfigured = "not-configured",
ConfirmingConfigured = "confirming-configured",
ConfirmationError = "confirmation-error",
AwaitingOAuth = "awaiting-oauth",
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 { isPremiumTier, setConfig, config } = useContext(AppContext);
const [entraPhase, setEntraPhase] = useState<EntraPhase>(
EntraPhase.NotConfigured
);
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);
// Modal states
const [showEntraModal, setShowEntraModal] = useState(false);
const [showOktaModal, setShowOktaModal] = useState(false);
const [providerToDelete, setProviderToDelete] = useState<
"microsoft-entra" | "okta" | null
>(null);
// "loading" state here is encompassed by phase === Phase.ConfirmingConfigured state, don't need
// to use useQuery's
@@ -200,16 +178,16 @@ const ConditionalAccess = () => {
>(["confirmAccess"], conditionalAccessAPI.confirmMicrosoftConditionalAccess, {
...DEFAULT_USE_QUERY_OPTIONS,
// only make this call at the appropriate UI phase
enabled: phase === Phase.ConfirmingConfigured && isPremiumTier,
enabled: entraPhase === EntraPhase.ConfirmingConfigured && isPremiumTier,
onSuccess: ({ configuration_completed, setup_error }) => {
if (configuration_completed) {
setPhase(Phase.Configured);
setEntraPhase(EntraPhase.Configured);
renderFlash(
"success",
"Successfully verified conditional access integration"
"Successfully verified Microsoft Entra conditional access integration"
);
} else {
setPhase(Phase.Form);
setEntraPhase(EntraPhase.NotConfigured);
if (
// IT admin did not complete the consent.
@@ -250,26 +228,60 @@ const ConditionalAccess = () => {
},
onError: () => {
// distinct from successful confirmation response of `false`, this handles an API error
setPhase(Phase.ConfirmationError);
setEntraPhase(EntraPhase.ConfirmationError);
},
});
const {
microsoft_entra_tenant_id: contextConfigMsetId,
microsoft_entra_connection_configured: contextConfigMseConfigured,
} = contextConfig?.conditional_access || {};
microsoft_entra_tenant_id: entraTenantId,
microsoft_entra_connection_configured: entraConfigured,
okta_idp_id: oktaIdpId,
okta_assertion_consumer_service_url: oktaAcsUrl,
okta_audience_uri: oktaAudienceUri,
okta_certificate: oktaCertificate,
} = config?.conditional_access || {};
// only checks if tenant id already present in config, not if user added it to the form
// Determine if Okta is configured (all 4 fields must be present)
const oktaConfigured = !!(
oktaIdpId &&
oktaAcsUrl &&
oktaAudienceUri &&
oktaCertificate
);
// Check if this is a managed cloud deployment (Microsoft Entra requires proxy infrastructure)
const isManagedCloud = config?.license?.managed_cloud || false;
// Check Entra configuration state
// Note: entraPhase is intentionally included in the dependency array to allow
// manual phase overrides (e.g., AwaitingOAuth) to persist until config changes
useEffect(() => {
if (contextConfigMsetId) {
if (!contextConfigMseConfigured) {
setPhase(Phase.ConfirmingConfigured);
// Don't check config if we're in AwaitingOAuth phase
if (entraPhase === EntraPhase.AwaitingOAuth) {
return;
}
// Don't override if we just successfully confirmed (phase is Configured but config not yet updated)
// However, if the tenant ID is removed (deleted), we should still update to NotConfigured
if (
entraPhase === EntraPhase.Configured &&
!entraConfigured &&
entraTenantId
) {
return;
}
if (entraTenantId) {
if (!entraConfigured) {
setEntraPhase(EntraPhase.ConfirmingConfigured);
} else {
// tenant id is present and connection is configured
setPhase(Phase.Configured);
setEntraPhase(EntraPhase.Configured);
}
} else {
setEntraPhase(EntraPhase.NotConfigured);
}
}, [contextConfigMsetId, contextConfigMseConfigured]);
}, [entraTenantId, entraConfigured, entraPhase]);
if (!isPremiumTier) {
return <PremiumFeatureMessage />;
@@ -277,146 +289,168 @@ const ConditionalAccess = () => {
// HANDLERS
const toggleDeleteConditionalAccessModal = () => {
setShowDeleteConditionalAccessModal(!showDeleteConditionalAccessModal);
const toggleDeleteModal = () => {
setProviderToDelete(null);
};
const onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const toggleEntraModal = () => {
setShowEntraModal(!showEntraModal);
};
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]
const handleEntraModalSuccess = () => {
setShowEntraModal(false);
// Set phase to awaiting OAuth instead of immediately refetching config
// Config will be checked when user refreshes the page
setEntraPhase(EntraPhase.AwaitingOAuth);
};
const onDeleteConditionalAccess = (updatedConfig: IConfig) => {
setConfig(updatedConfig);
};
const toggleOktaModal = () => {
setShowOktaModal(!showOktaModal);
};
const handleOktaModalSuccess = (updatedConfig: IConfig) => {
setShowOktaModal(false);
setConfig(updatedConfig);
};
const handleEntraDelete = () => {
setProviderToDelete("microsoft-entra");
};
const handleOktaDelete = () => {
setProviderToDelete("okta");
};
// RENDER
const renderOktaContent = () => {
return (
<SectionCard
header={oktaConfigured ? undefined : "Okta"}
iconName={oktaConfigured ? "success" : undefined}
cta={
oktaConfigured ? (
<Button variant="text-icon" onClick={handleOktaDelete}>
Delete
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
) : (
<Button onClick={toggleOktaModal}>Connect</Button>
)
}
>
{oktaConfigured
? "Okta conditional access configured"
: "Connect Okta to enable conditional access."}
</SectionCard>
);
};
const renderEntraContent = () => {
if (entraPhase === EntraPhase.ConfirmingConfigured) {
return (
<SectionCard header="Microsoft Entra">
<Spinner />
</SectionCard>
);
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();
};
if (entraPhase === EntraPhase.ConfirmationError) {
return (
<SectionCard header="Microsoft Entra">
<DataError />
</SectionCard>
);
}
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);
};
// Compute Entra card props to avoid nested ternaries
const entraIsConfigured = entraPhase === EntraPhase.Configured;
const entraIsAwaitingOAuth = entraPhase === EntraPhase.AwaitingOAuth;
const onInputBlur = () => {
setFormErrors(validate(formData));
let entraIconName: IconNames | undefined;
if (entraIsConfigured) {
entraIconName = "success";
} else if (entraIsAwaitingOAuth) {
entraIconName = "pending-outline";
}
let entraCta: React.JSX.Element | undefined;
if (entraIsConfigured) {
entraCta = (
<Button variant="text-icon" onClick={handleEntraDelete}>
Delete
<Icon name="trash" color="ui-fleet-black-75" />
</Button>
);
} else if (!entraIsAwaitingOAuth) {
entraCta = <Button onClick={toggleEntraModal}>Connect</Button>;
}
let entraContent: string;
if (entraIsConfigured) {
entraContent = "Microsoft Entra conditional access configured";
} else if (entraIsAwaitingOAuth) {
entraContent =
"To complete your integration, follow the instructions in the other tab, then refresh this page to verify.";
} else {
entraContent = "Connect Entra to enable conditional access.";
}
return (
<SectionCard
header={
entraIsConfigured || entraIsAwaitingOAuth
? undefined
: "Microsoft Entra"
}
iconName={entraIconName}
cta={entraCta}
>
{entraContent}
</SectionCard>
);
};
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="inverse"
onClick={toggleDeleteConditionalAccessModal}
>
Delete
<Icon name="trash" />
</Button>
</InfoBanner>
);
default:
return <Spinner />;
}
return (
<div className={`${baseClass}__cards`}>
{renderOktaContent()}
{isManagedCloud && renderEntraContent()}
</div>
);
};
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{" "}
Block hosts failing policies from logging in with single sign-on. Once
connected, enable or disable on the{" "}
<CustomLink url={paths.MANAGE_POLICIES} text="Policies" /> page.
</p>
{renderContent()}
{showDeleteConditionalAccessModal && (
{showEntraModal && (
<EntraConditionalAccessModal
onCancel={toggleEntraModal}
onSuccess={handleEntraModalSuccess}
/>
)}
{showOktaModal && (
<OktaConditionalAccessModal
onCancel={toggleOktaModal}
onSuccess={handleOktaModalSuccess}
/>
)}
{providerToDelete && (
<DeleteConditionalAccessModal
onDelete={onDeleteConditionalAccess}
toggleDeleteConditionalAccessModal={
toggleDeleteConditionalAccessModal
}
toggleDeleteConditionalAccessModal={toggleDeleteModal}
provider={providerToDelete}
config={config}
/>
)}
</div>
@@ -1,4 +1,14 @@
.conditional-access {
&__page-description {
margin-bottom: $pad-large;
}
&__cards {
display: flex;
flex-direction: column;
gap: $pad-large;
}
&__success {
.info-banner__info {
display: flex;
@@ -0,0 +1,135 @@
import React, { useContext, useState } from "react";
import { size } from "lodash";
import { NotificationContext } from "context/notification";
import conditionalAccessAPI from "services/entities/conditional_access";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import CustomLink from "components/CustomLink";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import { IInputFieldParseTarget } from "interfaces/form_field";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
const baseClass = "entra-conditional-access-modal";
const MSETID = "microsoft_entra_tenant_id";
interface IFormData {
[MSETID]: string;
}
interface IFormErrors {
[MSETID]?: string | null;
}
const validate = (formData: IFormData) => {
const errs: IFormErrors = {};
if (!formData[MSETID]) {
errs[MSETID] = "Tenant ID must be present";
}
return errs;
};
export interface IEntraConditionalAccessModalProps {
onCancel: () => void;
onSuccess: () => void;
}
const EntraConditionalAccessModal = ({
onCancel,
onSuccess,
}: IEntraConditionalAccessModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isUpdating, setIsUpdating] = useState(false);
const [formData, setFormData] = useState<IFormData>({
[MSETID]: "",
});
const [formErrors, setFormErrors] = useState<IFormErrors>({});
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]
);
window.open(msAuthURL);
setIsUpdating(false);
// Close modal and show banner on main page
onSuccess();
} catch (e) {
renderFlash(
"error",
"Could not update conditional access integration settings."
);
setIsUpdating(false);
}
};
const onInputChange = ({ name, value }: IInputFieldParseTarget) => {
setFormData({ ...formData, [name]: value });
setFormErrors({}); // Clear any existing error
};
const onInputBlur = () => {
setFormErrors(validate(formData));
};
return (
<Modal
title="Microsoft Entra conditional access"
onExit={onCancel}
className={baseClass}
width="large"
>
<>
<form onSubmit={onSubmit} autoComplete="off">
<p className={`${baseClass}__instructions`}>
To configure Microsoft Entra conditional access, follow the
instructions in the{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/entra-conditional-access`}
text="guide"
newTab
/>
</p>
<InputField
label="Microsoft Entra tenant ID"
helpText="You can find this in your Microsoft Entra admin center."
onChange={onInputChange}
name={MSETID}
value={formData[MSETID]}
parseTarget
onBlur={onInputBlur}
error={formErrors[MSETID]}
/>
<div className="modal-cta-wrap">
<Button
type="submit"
disabled={!!size(formErrors)}
isLoading={isUpdating}
>
Save
</Button>
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
</div>
</form>
</>
</Modal>
);
};
export default EntraConditionalAccessModal;
@@ -0,0 +1 @@
export { default } from "./EntraConditionalAccessModal";
@@ -0,0 +1,392 @@
import React, { useCallback, useContext, useState } from "react";
import { size } from "lodash";
import { useQuery } from "react-query";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import configAPI from "services/entities/config";
import conditionalAccessAPI from "services/entities/conditional_access";
import { IConfig } from "interfaces/config";
import endpoints from "utilities/endpoints";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import CustomLink from "components/CustomLink";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import TooltipWrapper from "components/TooltipWrapper";
import { IInputFieldParseTarget } from "interfaces/form_field";
import { getErrorReason } from "interfaces/errors";
import {
DEFAULT_USE_QUERY_OPTIONS,
LEARN_MORE_ABOUT_BASE_LINK,
} from "utilities/constants";
import FileUploader from "components/FileUploader";
import valid_url from "components/forms/validators/valid_url";
const baseClass = "okta-conditional-access-modal";
const OKTA_IDP_ID = "okta_idp_id";
const OKTA_ACS_URL = "okta_acs_url";
const OKTA_AUDIENCE_URI = "okta_audience_uri";
const OKTA_CERTIFICATE = "okta_certificate";
interface IFormData {
[OKTA_IDP_ID]: string;
[OKTA_ACS_URL]: string;
[OKTA_AUDIENCE_URI]: string;
[OKTA_CERTIFICATE]: string;
}
interface IFormErrors {
[OKTA_IDP_ID]?: string | null;
[OKTA_ACS_URL]?: string | null;
[OKTA_AUDIENCE_URI]?: string | null;
[OKTA_CERTIFICATE]?: string | null;
}
const validate = (formData: IFormData) => {
const errs: IFormErrors = {};
// Max lengths from backend validation
const maxURLLength = 2048;
const maxCertLength = 8192;
// IdP ID validation - must be non-empty and not just whitespace
if (!formData[OKTA_IDP_ID] || !formData[OKTA_IDP_ID].trim()) {
errs[OKTA_IDP_ID] = "IdP ID must be present";
} else if (formData[OKTA_IDP_ID].length > maxURLLength) {
errs[OKTA_IDP_ID] = `IdP ID must be ${maxURLLength} characters or less`;
}
// Assertion Consumer Service URL validation
if (!formData[OKTA_ACS_URL] || !formData[OKTA_ACS_URL].trim()) {
errs[OKTA_ACS_URL] = "Assertion Consumer Service URL must be present";
} else if (formData[OKTA_ACS_URL].length > maxURLLength) {
errs[
OKTA_ACS_URL
] = `Assertion Consumer Service URL must be ${maxURLLength} characters or less`;
} else if (
!valid_url({ url: formData[OKTA_ACS_URL], protocols: ["http", "https"] })
) {
errs[OKTA_ACS_URL] =
"Assertion Consumer Service URL must be a valid URL with http or https scheme and a host";
}
// Audience URI validation
if (!formData[OKTA_AUDIENCE_URI] || !formData[OKTA_AUDIENCE_URI].trim()) {
errs[OKTA_AUDIENCE_URI] = "Audience URI must be present";
} else if (formData[OKTA_AUDIENCE_URI].length > maxURLLength) {
errs[
OKTA_AUDIENCE_URI
] = `Audience URI must be ${maxURLLength} characters or less`;
}
// Certificate validation
if (!formData[OKTA_CERTIFICATE] || !formData[OKTA_CERTIFICATE].trim()) {
errs[OKTA_CERTIFICATE] = "Certificate must be present";
} else if (formData[OKTA_CERTIFICATE].length > maxCertLength) {
errs[
OKTA_CERTIFICATE
] = `Certificate must be ${maxCertLength} characters or less`;
}
return errs;
};
export interface IOktaConditionalAccessModalProps {
onCancel: () => void;
onSuccess: (updatedConfig: IConfig) => void;
}
const OktaConditionalAccessModal = ({
onCancel,
onSuccess,
}: IOktaConditionalAccessModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const { config } = useContext(AppContext);
const [isUpdating, setIsUpdating] = useState(false);
const [formData, setFormData] = useState<IFormData>({
[OKTA_IDP_ID]: "",
[OKTA_ACS_URL]: "",
[OKTA_AUDIENCE_URI]: "",
[OKTA_CERTIFICATE]: "",
});
const [formErrors, setFormErrors] = useState<IFormErrors>({});
const [certFile, setCertFile] = useState<File | null>(null);
// Fetch Apple profile with automatic retries
const { data: appleProfile = "" } = useQuery<string, Error>(
["appleProfile"],
conditionalAccessAPI.getIdpAppleProfile,
{
...DEFAULT_USE_QUERY_OPTIONS,
onError: (e: any) => {
// When responseType is "text", error responses come back as JSON strings
// that need to be parsed manually
let errorReason = "";
try {
if (e.data && typeof e.data === "string") {
const parsedError = JSON.parse(e.data);
errorReason = parsedError.errors?.[0]?.reason || "";
} else {
errorReason = getErrorReason(e);
}
} catch {
errorReason = getErrorReason(e);
}
const message = errorReason
? `Failed to load Apple profile: ${errorReason}`
: "Failed to load Apple profile.";
renderFlash("error", message);
},
}
);
const onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) {
setFormErrors(errs);
return;
}
if (!config) {
return;
}
setIsUpdating(true);
try {
const updatedConfig = await configAPI.update({
conditional_access: {
okta_idp_id: formData[OKTA_IDP_ID],
okta_assertion_consumer_service_url: formData[OKTA_ACS_URL],
okta_audience_uri: formData[OKTA_AUDIENCE_URI],
okta_certificate: formData[OKTA_CERTIFICATE],
// Preserve existing Microsoft Entra settings
microsoft_entra_tenant_id:
config.conditional_access?.microsoft_entra_tenant_id || "",
},
});
renderFlash("success", "Successfully configured Okta conditional access");
setIsUpdating(false);
onSuccess(updatedConfig);
} catch (e) {
renderFlash(
"error",
"Could not update conditional access integration settings."
);
setIsUpdating(false);
}
};
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 onDeleteFile = () => {
setCertFile(null);
setFormData({ ...formData, [OKTA_CERTIFICATE]: "" });
setFormErrors({
...formErrors,
[OKTA_CERTIFICATE]: "Certificate must be present",
});
};
const onSelectFile = useCallback(
(files: FileList | null) => {
const file = files?.[0];
if (!file) return;
// Validate file extension
if (!file.name.match(/\.(pem|crt|cer|cert)$/i)) {
renderFlash(
"error",
"Invalid file type. Please upload a .pem, .crt, .cer, or .cert file."
);
return;
}
const reader = new FileReader();
reader.readAsText(file);
reader.addEventListener("load", () => {
const content = reader.result as string;
// Validate PEM format
if (
!content.includes("-----BEGIN CERTIFICATE-----") ||
!content.includes("-----END CERTIFICATE-----")
) {
renderFlash(
"error",
"Invalid certificate format. The file must be a valid PEM-encoded certificate."
);
return;
}
// Create new form data with the certificate
const newFormData = { ...formData, [OKTA_CERTIFICATE]: content };
// Store the certificate content and file details
setCertFile(file);
setFormData(newFormData);
// Re-validate the entire form to clear errors if all fields are now complete
setFormErrors(validate(newFormData));
});
reader.addEventListener("error", () => {
renderFlash("error", "Failed to read the certificate file.");
});
},
[formData, renderFlash]
);
return (
<Modal
title="Okta conditional access"
onExit={onCancel}
className={baseClass}
width="xlarge"
>
<>
<form onSubmit={onSubmit} autoComplete="off">
<p className={`${baseClass}__instructions`}>
To configure Okta conditional access, follow the instructions in the{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/okta-conditional-access`}
text="guide"
newTab
/>
</p>
{/* IdP Signature Certificate Section */}
<div className={`${baseClass}__certificate-section`}>
<TooltipWrapper
tipContent="Upload this certificate in Okta when creating the Fleet IdP."
underline
>
Identity provider (IdP) signature certificate
</TooltipWrapper>
<br />
<a
href={endpoints.CONDITIONAL_ACCESS_IDP_SIGNING_CERT}
download="fleet-idp-signing-certificate.pem"
className="button button--inverse"
>
<div className="children-wrapper">
Download certificate <Icon name="download" />
</div>
</a>
</div>
{/* User Scope Profile */}
<InputField
enableCopy
label="User scope profile"
readOnly
value={appleProfile}
type="textarea"
/>
{/* Help text */}
<p className={`${baseClass}__field-instructions`}>
You can find the following fields in Okta after creating an IdP in{" "}
<strong>Security</strong> &gt; <strong>Identity Providers</strong>{" "}
&gt; <strong>SAML 2.0 IdP</strong>.
</p>
<InputField
label="IdP ID"
onChange={onInputChange}
name={OKTA_IDP_ID}
value={formData[OKTA_IDP_ID]}
parseTarget
onBlur={onInputBlur}
error={formErrors[OKTA_IDP_ID]}
/>
<InputField
label="Assertion consumer service URL"
onChange={onInputChange}
name={OKTA_ACS_URL}
value={formData[OKTA_ACS_URL]}
parseTarget
onBlur={onInputBlur}
error={formErrors[OKTA_ACS_URL]}
/>
<InputField
label="Audience URI"
onChange={onInputChange}
name={OKTA_AUDIENCE_URI}
value={formData[OKTA_AUDIENCE_URI]}
parseTarget
onBlur={onInputBlur}
error={formErrors[OKTA_AUDIENCE_URI]}
/>
{/* Certificate file uploader with inline validation error display.
Note: This is a custom pattern - FileUploader doesn't have built-in error prop like InputField.
Other FileUploader usages in the codebase use flash notifications instead of inline errors,
but this form requires field-level validation consistency with the InputFields above. */}
<div className={`${baseClass}__file-uploader-wrapper`}>
{formErrors[OKTA_CERTIFICATE] && (
<span className={`${baseClass}__file-uploader-error`}>
{formErrors[OKTA_CERTIFICATE]}
</span>
)}
<FileUploader
graphicName="file-pem"
title="Okta certificate"
message={
<>
Upload the certificate provided by Okta during the{" "}
<strong>Set Up Authenticator</strong> workflow
</>
}
onFileUpload={onSelectFile}
buttonType="brand-inverse-icon"
buttonMessage="Upload"
accept=".pem,.crt,.cer,.cert"
fileDetails={certFile ? { name: certFile.name } : undefined}
onDeleteFile={onDeleteFile}
/>
</div>
<div className="modal-cta-wrap">
<Button
type="submit"
disabled={!!size(formErrors)}
isLoading={isUpdating}
>
Save
</Button>
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
</div>
</form>
</>
</Modal>
);
};
export default OktaConditionalAccessModal;
@@ -0,0 +1,28 @@
.okta-conditional-access-modal {
.file-uploader__title {
color: $ui-fleet-black-75;
font-size: $small;
font-weight: $bold;
}
// User scope profile read-only textarea
.input-field--read-only {
.input-field__textarea {
background-color: $ui-off-white;
}
}
// Error message above file uploader
&__file-uploader-error {
color: $core-vibrant-red;
font-size: $x-small;
font-weight: $bold;
}
// Certificate file uploader wrapper
&__file-uploader-wrapper {
display: flex;
flex-direction: column;
gap: $pad-small; // 0.5rem gap between error and FileUploader
}
}
@@ -0,0 +1 @@
export { default } from "./OktaConditionalAccessModal";
@@ -24,6 +24,14 @@ const conditionalAccessService = {
deleteMicrosoftConditionalAccess: () => {
return sendRequest("DELETE", endpoints.CONDITIONAL_ACCESS_MICROSOFT);
},
getIdpAppleProfile: (): Promise<string> => {
return sendRequest(
"GET",
endpoints.CONDITIONAL_ACCESS_IDP_APPLE_PROFILE,
undefined,
"text"
);
},
};
export default conditionalAccessService;
+2
View File
@@ -18,6 +18,8 @@ export default {
// Conditional access
CONDITIONAL_ACCESS_MICROSOFT: `/${API_VERSION}/fleet/conditional-access/microsoft`,
CONDITIONAL_ACCESS_MICROSOFT_CONFIRM: `/${API_VERSION}/fleet/conditional-access/microsoft/confirm`,
CONDITIONAL_ACCESS_IDP_SIGNING_CERT: `/${API_VERSION}/fleet/conditional_access/idp/signing_cert`,
CONDITIONAL_ACCESS_IDP_APPLE_PROFILE: `/${API_VERSION}/fleet/conditional_access/idp/apple/profile`,
CONFIG: `/${API_VERSION}/fleet/config`,
CONFIRM_EMAIL_CHANGE: (token: string): string => {