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:
co-authored by
Jacob Shandling
parent
8a25886781
commit
767c594ad8
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
+207
-1
@@ -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>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+332
-14
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+257
-223
@@ -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'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;
|
||||
|
||||
+135
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./EntraConditionalAccessModal";
|
||||
+392
@@ -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> > <strong>Identity Providers</strong>{" "}
|
||||
> <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;
|
||||
+28
@@ -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
|
||||
}
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user