From d100dcbf66c1b355fb7b5cd84b72f7c15f9987e0 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 3 Sep 2026 03:39:21 +0000 Subject: [PATCH 01/11] fix: cleanup idp syncing logic --- .../idpSync/ExportPolicyButton.stories.tsx | 100 ++++++++++++++++++ .../modules/idpSync/ExportPolicyButton.tsx | 66 ++++++++++++ .../idpSync/IdpUnseenClaimWarning.stories.tsx | 23 ++++ .../modules/idpSync/IdpUnseenClaimWarning.tsx | 33 ++++++ .../ExportPolicyButton.stories.tsx | 42 -------- .../IdpOrgSyncPage/ExportPolicyButton.tsx | 50 --------- .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 56 ++++------ .../IdpOrgSyncPageView.stories.tsx | 18 +++- .../IdpOrgSyncPage/IdpOrgSyncPageView.tsx | 26 +---- .../ExportPolicyButton.stories.tsx | 76 ------------- .../IdpSyncPage/ExportPolicyButton.tsx | 59 ----------- .../IdpSyncPage/IdpGroupSyncForm.tsx | 32 ++---- .../IdpSyncPage/IdpRoleSyncForm.tsx | 32 ++---- .../IdpSyncPage/IdpSyncPage.tsx | 50 ++++----- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 27 +++-- 15 files changed, 307 insertions(+), 383 deletions(-) create mode 100644 site/src/modules/idpSync/ExportPolicyButton.stories.tsx create mode 100644 site/src/modules/idpSync/ExportPolicyButton.tsx create mode 100644 site/src/modules/idpSync/IdpUnseenClaimWarning.stories.tsx create mode 100644 site/src/modules/idpSync/IdpUnseenClaimWarning.tsx delete mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx delete mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx delete mode 100644 site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx delete mode 100644 site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx diff --git a/site/src/modules/idpSync/ExportPolicyButton.stories.tsx b/site/src/modules/idpSync/ExportPolicyButton.stories.tsx new file mode 100644 index 00000000000..5045b200af8 --- /dev/null +++ b/site/src/modules/idpSync/ExportPolicyButton.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import type { Mock } from "vitest"; +import { + MockGroupSyncSettings, + MockOrganization, + MockOrganizationSyncSettings, + MockRoleSyncSettings, +} from "#/testHelpers/entities"; +import { ExportPolicyButton } from "./ExportPolicyButton"; + +const meta: Meta = { + title: "modules/idpSync/ExportPolicyButton", + component: ExportPolicyButton, + args: { + syncSettings: MockGroupSyncSettings, + filename: `${MockOrganization.name}_groups-policy.json`, + size: "sm", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +const expectExportedPolicy = async ( + canvasElement: HTMLElement, + download: Mock | undefined, + filename: string, + settings: unknown, +) => { + const canvas = within(canvasElement); + await expect(download).toBeDefined(); + if (!download) { + return; + } + + await userEvent.click(canvas.getByRole("button", { name: "Export policy" })); + await waitFor(() => + expect(download).toHaveBeenCalledWith(expect.anything(), filename), + ); + const [blob] = download.mock.lastCall ?? []; + await expect(blob).toBeInstanceOf(Blob); + if (!(blob instanceof Blob)) { + return; + } + await expect(blob.type).toEqual("application/json"); + await expect(await blob.text()).toEqual(JSON.stringify(settings, null, 2)); +}; + +export const ClickExportGroupPolicy: Story = { + args: { + syncSettings: MockGroupSyncSettings, + filename: `${MockOrganization.name}_groups-policy.json`, + size: "sm", + download: fn(), + }, + play: async ({ canvasElement, args }) => { + await expectExportedPolicy( + canvasElement, + args.download as Mock | undefined, + `${MockOrganization.name}_groups-policy.json`, + MockGroupSyncSettings, + ); + }, +}; + +export const ClickExportRolePolicy: Story = { + args: { + syncSettings: MockRoleSyncSettings, + filename: `${MockOrganization.name}_roles-policy.json`, + size: "sm", + download: fn(), + }, + play: async ({ canvasElement, args }) => { + await expectExportedPolicy( + canvasElement, + args.download as Mock | undefined, + `${MockOrganization.name}_roles-policy.json`, + MockRoleSyncSettings, + ); + }, +}; + +export const ClickExportOrganizationPolicy: Story = { + args: { + syncSettings: MockOrganizationSyncSettings, + filename: "organizations_policy.json", + download: fn(), + }, + play: async ({ canvasElement, args }) => { + await expectExportedPolicy( + canvasElement, + args.download as Mock | undefined, + "organizations_policy.json", + MockOrganizationSyncSettings, + ); + }, +}; diff --git a/site/src/modules/idpSync/ExportPolicyButton.tsx b/site/src/modules/idpSync/ExportPolicyButton.tsx new file mode 100644 index 00000000000..b1f40d83d39 --- /dev/null +++ b/site/src/modules/idpSync/ExportPolicyButton.tsx @@ -0,0 +1,66 @@ +import { saveAs } from "file-saver"; +import { DownloadIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { toast } from "sonner"; +import { getErrorDetail } from "#/api/errors"; +import type { + GroupSyncSettings, + OrganizationSyncSettings, + RoleSyncSettings, +} from "#/api/typesGenerated"; +import { Button, type ButtonProps } from "#/components/Button/Button"; + +type ExportableSyncSettings = + | GroupSyncSettings + | RoleSyncSettings + | OrganizationSyncSettings; + +interface ExportPolicyButtonProps { + syncSettings: ExportableSyncSettings | undefined; + filename: string; + size?: ButtonProps["size"]; + download?: (file: Blob, filename: string) => void; +} + +export const ExportPolicyButton: FC = ({ + syncSettings, + filename, + size, + download = saveAs, +}) => { + const [isDownloading, setIsDownloading] = useState(false); + + const canCreatePolicyJson = + Boolean(syncSettings?.field) && + Object.keys(syncSettings?.mapping ?? {}).length > 0; + + return ( + + ); +}; diff --git a/site/src/modules/idpSync/IdpUnseenClaimWarning.stories.tsx b/site/src/modules/idpSync/IdpUnseenClaimWarning.stories.tsx new file mode 100644 index 00000000000..cd4f24aec0c --- /dev/null +++ b/site/src/modules/idpSync/IdpUnseenClaimWarning.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { screen, userEvent, within } from "storybook/test"; +import { IdpUnseenClaimWarning } from "./IdpUnseenClaimWarning"; + +const meta: Meta = { + title: "modules/idpSync/IdpUnseenClaimWarning", + component: IdpUnseenClaimWarning, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.hover( + canvas.getByRole("button", { name: "Unknown claim value" }), + ); + await screen.findByRole("tooltip", { + name: /has not be seen in the specified claim field/i, + }); + }, +}; diff --git a/site/src/modules/idpSync/IdpUnseenClaimWarning.tsx b/site/src/modules/idpSync/IdpUnseenClaimWarning.tsx new file mode 100644 index 00000000000..abf4bc8b95e --- /dev/null +++ b/site/src/modules/idpSync/IdpUnseenClaimWarning.tsx @@ -0,0 +1,33 @@ +import { TriangleAlertIcon } from "lucide-react"; +import type { FC } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; + +export const IdpUnseenClaimWarning: FC = () => { + return ( + + + + + + This value has not be seen in the specified claim field before. You + might want to check your IdP configuration and ensure that this value is + not misspelled. + + + ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx deleted file mode 100644 index 0dc01ba2fc7..00000000000 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type { Mock } from "vitest"; -import { MockOrganizationSyncSettings } from "#/testHelpers/entities"; -import { ExportPolicyButton } from "./ExportPolicyButton"; - -const meta: Meta = { - title: "pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton", - component: ExportPolicyButton, - args: { - syncSettings: MockOrganizationSyncSettings, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const ClickExportPolicy: Story = { - args: { - syncSettings: MockOrganizationSyncSettings, - download: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - await userEvent.click( - canvas.getByRole("button", { name: "Export Policy" }), - ); - await waitFor(() => - expect(args.download).toHaveBeenCalledWith( - expect.anything(), - "organizations_policy.json", - ), - ); - const blob: Blob = (args.download as Mock).mock.lastCall![0]; - await expect(blob.type).toEqual("application/json"); - await expect(await blob.text()).toEqual( - JSON.stringify(MockOrganizationSyncSettings, null, 2), - ); - }, -}; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx deleted file mode 100644 index 1aaea617635..00000000000 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { saveAs } from "file-saver"; -import { DownloadIcon } from "lucide-react"; -import { type FC, useState } from "react"; -import { toast } from "sonner"; -import { getErrorDetail } from "#/api/errors"; -import type { OrganizationSyncSettings } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; - -interface ExportPolicyButtonProps { - syncSettings: OrganizationSyncSettings | undefined; - download?: (file: Blob, filename: string) => void; -} - -export const ExportPolicyButton: FC = ({ - syncSettings, - download = saveAs, -}) => { - const [isDownloading, setIsDownloading] = useState(false); - - const canCreatePolicyJson = - syncSettings?.field && Object.keys(syncSettings?.mapping).length > 0; - - return ( - - ); -}; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index 48163500d23..5cf84adfcf0 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useState } from "react"; +import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; @@ -17,10 +17,10 @@ import { import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; +import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; import { PremiumPaywall } from "#/modules/paywall/PremiumPaywall"; import { docs } from "#/utils/docs"; import { pageTitle } from "#/utils/page"; -import { ExportPolicyButton } from "./ExportPolicyButton"; import { IdpOrgSyncPageView } from "./IdpOrgSyncPageView"; const IdpOrgSyncPage: FC = () => { @@ -30,15 +30,8 @@ const IdpOrgSyncPage: FC = () => { const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility(); const { organizations } = useDashboard(); const settingsQuery = useQuery(organizationIdpSyncSettings(isIdpSyncEnabled)); - - const [field, setField] = useState(""); - useEffect(() => { - if (!settingsQuery.data) { - return; - } - - setField(settingsQuery.data.field); - }, [settingsQuery.data]); + const [fieldOverride, setFieldOverride] = useState(); + const field = fieldOverride ?? settingsQuery.data?.field ?? ""; const fieldValuesQuery = useQuery({ ...deploymentIdpSyncFieldValues(field), @@ -49,17 +42,6 @@ const IdpOrgSyncPage: FC = () => { patchOrganizationSyncSettings(queryClient), ); - useEffect(() => { - if (patchOrganizationSyncSettingsMutation.error) { - toast.error( - getErrorMessage( - patchOrganizationSyncSettingsMutation.error, - "Error updating organization IdP sync settings.", - ), - ); - } - }, [patchOrganizationSyncSettingsMutation.error]); - if (settingsQuery.isLoading) { return ; } @@ -70,7 +52,12 @@ const IdpOrgSyncPage: FC = () => {
} + actions={ + + } > Organization IdP Sync @@ -98,22 +85,21 @@ const IdpOrgSyncPage: FC = () => { organizationSyncSettings={settingsQuery.data} claimFieldValues={fieldValuesQuery.data} organizations={organizations} - onSyncFieldChange={setField} + onSyncFieldChange={setFieldOverride} onSubmit={async (data) => { - try { - await patchOrganizationSyncSettingsMutation.mutateAsync(data); - toast.success("Organization sync settings updated."); - } catch (error) { - toast.error( - getErrorMessage( + const mutation = + patchOrganizationSyncSettingsMutation.mutateAsync(data); + toast.promise(mutation, { + loading: "Updating organization IdP sync settings...", + success: "Organization IdP sync settings updated.", + error: (error) => ({ + message: getErrorMessage( error, "Failed to update organization IdP sync settings.", ), - { - description: getErrorDetail(error), - }, - ); - } + description: getErrorDetail(error), + }), + }); }} error={settingsQuery.error || fieldValuesQuery.error} /> diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx index 9a991a5b528..c92aa8055b2 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within } from "storybook/test"; +import { screen, userEvent, within } from "storybook/test"; import { MockOrganization, MockOrganization2, @@ -51,10 +51,18 @@ export const MissingClaims: Story = { claimFieldValues: [], }, play: async ({ canvasElement }) => { - const user = userEvent.setup(); - const warning = canvasElement.querySelector(".lucide-triangle-alert")!; - expect(warning).not.toBe(null); - await user.hover(warning); + const canvas = within(canvasElement); + const warnings = canvas.getAllByRole("button", { + name: "Unknown claim value", + }); + const warning = warnings[0]; + if (!warning) { + throw new Error("Expected an unknown claim warning"); + } + await userEvent.hover(warning); + await screen.findByRole("tooltip", { + name: /has not be seen in the specified claim field/i, + }); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx index fea9a488607..d0870fd5ada 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { PlusIcon, TrashIcon, TriangleAlertIcon } from "lucide-react"; +import { PlusIcon, TrashIcon } from "lucide-react"; import { type FC, type KeyboardEventHandler, useId, useState } from "react"; import * as Yup from "yup"; import type { @@ -49,11 +49,7 @@ import { TableRow, } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; +import { IdpUnseenClaimWarning } from "#/modules/idpSync/IdpUnseenClaimWarning"; import { docs } from "#/utils/docs"; import { isUUID } from "#/utils/uuid"; import { OrganizationPills } from "./OrganizationPills"; @@ -443,23 +439,7 @@ const OrganizationRow: FC = ({
{idpOrg} - {!exists && ( - - - - - - This value has not be seen in the specified claim field before. - You might want to check your IdP configuration and ensure that - this value is not misspelled. - - - )} + {!exists && }
diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx deleted file mode 100644 index d5d6d585a61..00000000000 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type { Mock } from "vitest"; -import { - MockGroupSyncSettings, - MockOrganization, - MockRoleSyncSettings, -} from "#/testHelpers/entities"; -import { ExportPolicyButton } from "./ExportPolicyButton"; - -const meta: Meta = { - title: "pages/IdpSyncPage/ExportPolicyButton", - component: ExportPolicyButton, - args: { - syncSettings: MockGroupSyncSettings, - type: "groups", - organization: MockOrganization, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const ClickExportGroupPolicy: Story = { - args: { - syncSettings: MockGroupSyncSettings, - type: "groups", - organization: MockOrganization, - download: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - await userEvent.click( - canvas.getByRole("button", { name: "Export policy" }), - ); - await waitFor(() => - expect(args.download).toHaveBeenCalledWith( - expect.anything(), - `${MockOrganization.name}_groups-policy.json`, - ), - ); - const blob: Blob = (args.download as Mock).mock.lastCall![0]; - await expect(blob.type).toEqual("application/json"); - await expect(await blob.text()).toEqual( - JSON.stringify(MockGroupSyncSettings, null, 2), - ); - }, -}; - -export const ClickExportRolePolicy: Story = { - args: { - syncSettings: MockRoleSyncSettings, - type: "roles", - organization: MockOrganization, - download: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - await userEvent.click( - canvas.getByRole("button", { name: "Export policy" }), - ); - await waitFor(() => - expect(args.download).toHaveBeenCalledWith( - expect.anything(), - `${MockOrganization.name}_roles-policy.json`, - ), - ); - const blob: Blob = (args.download as Mock).mock.lastCall![0]; - await expect(blob.type).toEqual("application/json"); - await expect(await blob.text()).toEqual( - JSON.stringify(MockRoleSyncSettings, null, 2), - ); - }, -}; diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx deleted file mode 100644 index b75165eedd4..00000000000 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { saveAs } from "file-saver"; -import { DownloadIcon } from "lucide-react"; -import { type FC, useState } from "react"; -import { toast } from "sonner"; -import { getErrorDetail } from "#/api/errors"; -import type { - GroupSyncSettings, - Organization, - RoleSyncSettings, -} from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; - -interface DownloadPolicyButtonProps { - syncSettings: RoleSyncSettings | GroupSyncSettings | undefined; - type: "groups" | "roles"; - organization: Organization; - download?: (file: Blob, filename: string) => void; -} - -export const ExportPolicyButton: FC = ({ - syncSettings, - type, - organization, - download = saveAs, -}) => { - const [isDownloading, setIsDownloading] = useState(false); - - const canCreatePolicyJson = - syncSettings?.field && Object.keys(syncSettings?.mapping).length > 0; - - return ( - - ); -}; diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 62380c680f8..c2de8a2590a 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { PlusIcon, TrashIcon, TriangleAlertIcon } from "lucide-react"; +import { PlusIcon, TrashIcon } from "lucide-react"; import { type FC, type KeyboardEventHandler, useId, useState } from "react"; import * as Yup from "yup"; import type { @@ -34,15 +34,11 @@ import { import { Spinner } from "#/components/Spinner/Spinner"; import { Switch } from "#/components/Switch/Switch"; import { TableCell, TableRow } from "#/components/Table/Table"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; import { isEveryoneGroup } from "#/modules/groups"; +import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; +import { IdpUnseenClaimWarning } from "#/modules/idpSync/IdpUnseenClaimWarning"; import { docs } from "#/utils/docs"; import { isUUID } from "#/utils/uuid"; -import { ExportPolicyButton } from "./ExportPolicyButton"; import { IdpMappingTable } from "./IdpMappingTable"; import { IdpPillList } from "./IdpPillList"; @@ -152,8 +148,8 @@ export const IdpGroupSyncForm: FC = ({
@@ -405,23 +401,7 @@ const GroupRow: FC = ({
{idpGroup} - {!exists && ( - - - - - - This value has not be seen in the specified claim field before. - You might want to check your IdP configuration and ensure that - this value is not misspelled. - - - )} + {!exists && }
diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx index 403c9256afb..beae61d1c3f 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { PlusIcon, TrashIcon, TriangleAlertIcon } from "lucide-react"; +import { PlusIcon, TrashIcon } from "lucide-react"; import { type FC, type KeyboardEventHandler, useId, useState } from "react"; import * as Yup from "yup"; import type { @@ -25,12 +25,8 @@ import { } from "#/components/MultiSelectCombobox/MultiSelectCombobox"; import { Spinner } from "#/components/Spinner/Spinner"; import { TableCell, TableRow } from "#/components/Table/Table"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { ExportPolicyButton } from "./ExportPolicyButton"; +import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; +import { IdpUnseenClaimWarning } from "#/modules/idpSync/IdpUnseenClaimWarning"; import { IdpMappingTable } from "./IdpMappingTable"; import { IdpPillList } from "./IdpPillList"; @@ -129,8 +125,8 @@ export const IdpRoleSyncForm: FC = ({
@@ -326,23 +322,7 @@ const RoleRow: FC = ({
{idpRole} - {!exists && ( - - - - - - This value has not be seen in the specified claim field before. - You might want to check your IdP configuration and ensure that - this value is not misspelled. - - - )} + {!exists && }
diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index 61c5169ccb8..90e62183efc 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useState } from "react"; +import { type FC, useState } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; @@ -37,8 +37,8 @@ const IdpSyncPage: FC = () => { organization: string; }; const { organization, organizationPermissions } = useOrganizationSettings(); - const [groupField, setGroupField] = useState(""); - const [roleField, setRoleField] = useState(""); + const [groupFieldOverride, setGroupFieldOverride] = useState(); + const [roleFieldOverride, setRoleFieldOverride] = useState(); const [ groupIdpSyncSettingsQuery, @@ -54,24 +54,12 @@ const IdpSyncPage: FC = () => { ], }); - useEffect(() => { - if (!groupIdpSyncSettingsQuery.data) { - return; - } - - setGroupField(groupIdpSyncSettingsQuery.data.field); - }, [groupIdpSyncSettingsQuery.data]); - - useEffect(() => { - if (!roleIdpSyncSettingsQuery.data) { - return; - } - - setRoleField(roleIdpSyncSettingsQuery.data.field); - }, [roleIdpSyncSettingsQuery.data]); - const [searchParams] = useSearchParams(); const tab = searchParams.get("tab") || "groups"; + const groupField = + groupFieldOverride ?? groupIdpSyncSettingsQuery.data?.field ?? ""; + const roleField = + roleFieldOverride ?? roleIdpSyncSettingsQuery.data?.field ?? ""; const field = tab === "groups" ? groupField : roleField; const fieldValuesQuery = useQuery({ @@ -154,8 +142,8 @@ const IdpSyncPage: FC = () => { groupsMap={groupsMap} roles={rolesQuery.data} organization={organization} - onGroupSyncFieldChange={setGroupField} - onRoleSyncFieldChange={setRoleField} + onGroupSyncFieldChange={setGroupFieldOverride} + onRoleSyncFieldChange={setRoleFieldOverride} error={error} onSubmitGroupSyncSettings={async (data) => { const mutation = patchGroupSyncSettingsMutation.mutateAsync(data); @@ -172,20 +160,18 @@ const IdpSyncPage: FC = () => { }); }} onSubmitRoleSyncSettings={async (data) => { - try { - await patchRoleSyncSettingsMutation.mutateAsync(data); - toast.success("IdP Role sync settings updated."); - } catch (error) { - toast.error( - getErrorMessage( + const mutation = patchRoleSyncSettingsMutation.mutateAsync(data); + toast.promise(mutation, { + loading: "Updating IdP role sync settings...", + success: "IdP role sync settings updated.", + error: (error) => ({ + message: getErrorMessage( error, "Failed to update IdP role sync settings.", ), - { - description: getErrorDetail(error), - }, - ); - } + description: getErrorDetail(error), + }), + }); }} /> )} diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx index dca9767d0eb..7aa89cfc5f6 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent } from "storybook/test"; +import { screen, userEvent, within } from "storybook/test"; import { MockGroup, MockGroup2, @@ -18,6 +18,21 @@ for (const group of [MockGroup, MockGroup2, MockGroup3]) { groupsMap.set(group.id, group.display_name || group.name); } +const hoverUnknownClaimWarning = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + const warnings = canvas.getAllByRole("button", { + name: "Unknown claim value", + }); + const warning = warnings[0]; + if (!warning) { + throw new Error("Expected an unknown claim warning"); + } + await userEvent.hover(warning); + await screen.findByRole("tooltip", { + name: /has not be seen in the specified claim field/i, + }); +}; + const meta: Meta = { title: "pages/IdpSyncPage", component: IdpSyncPageView, @@ -92,10 +107,7 @@ export const GroupsTabMissingClaims: Story = { claimFieldValues: [], }, play: async ({ canvasElement }) => { - const user = userEvent.setup(); - const warning = canvasElement.querySelector(".lucide-triangle-alert")!; - expect(warning).not.toBe(null); - await user.hover(warning); + await hoverUnknownClaimWarning(canvasElement); }, }; @@ -111,9 +123,6 @@ export const RolesTabMissingClaims: Story = { claimFieldValues: [], }, play: async ({ canvasElement }) => { - const user = userEvent.setup(); - const warning = canvasElement.querySelector(".lucide-triangle-alert")!; - expect(warning).not.toBe(null); - await user.hover(warning); + await hoverUnknownClaimWarning(canvasElement); }, }; From 15450c55eab066a34c8484c0831609abfab2a098 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 3 Sep 2026 03:43:24 +0000 Subject: [PATCH 02/11] fix: convert `(i)` to description --- .../IdpSyncPage/IdpGroupSyncForm.tsx | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index c2de8a2590a..d645110f20d 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -27,6 +27,10 @@ import { import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { Link } from "#/components/Link/Link"; +import { + StackLabel, + StackLabelHelperText, +} from "#/components/StackLabel/StackLabel"; import { MultiSelectCombobox, type Option, @@ -203,7 +207,7 @@ export const IdpGroupSyncForm: FC = ({

))}
-
+
= ({ }} /> - - + + Create groups from the IdP when they do not already exist in + Coder. + + +
@@ -425,20 +432,6 @@ const GroupRow: FC = ({ ); }; -const AutoCreateMissingGroupsHelpPopover: FC = () => { - return ( - - - - - Enabling auto create missing groups will automatically create groups - returned by the OIDC provider if they do not exist in Coder. - - - - ); -}; - const LegacyGroupSyncHeader: FC = () => { return (

From a34a16e9a910aabacafc62a81d28360f55cb5cbe Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 3 Sep 2026 03:49:13 +0000 Subject: [PATCH 03/11] fix: convert to setting headers --- .../IdpSyncPage/IdpGroupSyncForm.tsx | 472 +++++++++--------- .../IdpSyncPage/IdpRoleSyncForm.tsx | 339 +++++++------ .../IdpSyncPage/IdpSyncPageView.stories.tsx | 38 +- 3 files changed, 448 insertions(+), 401 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index d645110f20d..6b4fa7324ac 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -17,24 +17,18 @@ import { ComboboxList, ComboboxTrigger, } from "#/components/Combobox/Combobox"; -import { - HelpPopover, - HelpPopoverContent, - HelpPopoverIconTrigger, - HelpPopoverText, - HelpPopoverTitle, -} from "#/components/HelpPopover/HelpPopover"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; -import { Link } from "#/components/Link/Link"; -import { - StackLabel, - StackLabelHelperText, -} from "#/components/StackLabel/StackLabel"; import { MultiSelectCombobox, type Option, } from "#/components/MultiSelectCombobox/MultiSelectCombobox"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderDocsLink, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; import { Switch } from "#/components/Switch/Switch"; import { TableCell, TableRow } from "#/components/Table/Table"; @@ -147,34 +141,33 @@ export const IdpGroupSyncForm: FC = ({
-
- -
-
-
-
- - - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); - }} - className="w-72" - /> -
+
+ + + Sync field + + + If empty, group sync is deactivated. + + +
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); + }} + className="w-72" + /> +
+
+ = ({ }} className="min-w-40" /> -
-

- If empty, group sync is deactivated -

+
-
- {form.errors.field || - (form.errors.regex_filter && ( + {(form.errors.field || form.errors.regex_filter) && (

{form.errors.field || form.errors.regex_filter}

- ))} + )} +
-
- - { - void form.setFieldValue("auto_create_missing_groups", checked); - form.handleSubmit(); - }} - /> - -
); @@ -431,29 +451,3 @@ const GroupRow: FC = ({ ); }; - -const LegacyGroupSyncHeader: FC = () => { - return ( -

-
- Legacy group sync settings - - - - Legacy group sync settings - - These settings were configured using environment variables, and - only apply to the default organization. It is now recommended to - configure IdP sync via the CLI or the UI, which enables sync to be - configured for any organization, and for those settings to be - persisted without manually setting environment variables.{" "} - - Learn more… - - - - -
-

- ); -}; diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx index beae61d1c3f..2cdab5312fb 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -23,6 +23,11 @@ import { MultiSelectCombobox, type Option, } from "#/components/MultiSelectCombobox/MultiSelectCombobox"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; import { TableCell, TableRow } from "#/components/Table/Table"; import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; @@ -120,32 +125,32 @@ export const IdpRoleSyncForm: FC = ({
-
- -
-
- -
-
- { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); - }} - className="w-72" - /> +
+ + + Sync field + + + If empty, role sync is deactivated. + + +
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); + }} + className="w-72" + /> +
+ {form.errors.field && ( +

+ {form.errors.field} +

+ )}
-

- If empty, role sync is deactivated -

- {form.errors.field && ( -

- {form.errors.field} -

- )} -
-
- - {claimFieldValues ? ( - setIdpRoleName(value ?? "")} - > - - - - - - - {claimFieldValues - .filter((value) => - value - .toLowerCase() - .includes(comboInputValue.toLowerCase()), - ) - .map((value) => ( - setComboInputValue("")} - > - {value} - - ))} - - - - ) : ( - { - setIdpRoleName(event.target.value); +
+ + } + > + + Role mapping + + + Map IdP roles to Coder roles. + + +
+
+ + {claimFieldValues ? ( + setIdpRoleName(value ?? "")} + > + + + + + + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + + ) : ( + { + setIdpRoleName(event.target.value); + }} + /> + )} +
+
+ + ({ + label: role.display_name || role.name, + value: role.name, + }))} + hidePlaceholderWhenSelected + placeholder="Select role" + emptyIndicator={ +

+ All roles selected +

+ } /> - )} -
-
- - ({ - label: role.display_name || role.name, - value: role.name, - }))} - hidePlaceholderWhenSelected - placeholder="Select role" - emptyIndicator={ -

- All roles selected -

- } - /> -
-
-
- +
+
+
+ +
+ {form.errors.mapping && ( +

+ {Object.values(form.errors.mapping || {})} +

+ )} + + {roleSyncSettings?.mapping && + Object.entries(roleSyncSettings.mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpRole, roles]) => ( + + ))} +
- {form.errors.mapping && ( -

- {Object.values(form.errors.mapping || {})} -

- )} - - {roleSyncSettings?.mapping && - Object.entries(roleSyncSettings.mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpRole, roles]) => ( - - ))} -
); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx index 7aa89cfc5f6..f1d9a975439 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { screen, userEvent, within } from "storybook/test"; +import { expect, screen, userEvent, within } from "storybook/test"; import { MockGroup, MockGroup2, @@ -73,7 +73,23 @@ export const Empty: Story = { }, }; -export const Default: Story = {}; +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("heading", { name: "Sync field" }), + ).toBeVisible(); + await expect( + canvas.getByRole("heading", { name: "Auto create missing groups" }), + ).toBeVisible(); + await expect( + canvas.getByRole("heading", { name: "Group mapping" }), + ).toBeVisible(); + await expect( + canvas.queryByRole("heading", { name: "Legacy group sync" }), + ).not.toBeInTheDocument(); + }, +}; export const HasError: Story = { args: { @@ -100,6 +116,12 @@ export const WithLegacyMapping: Story = { MockLegacyMappingGroupSyncSettings.legacy_group_name_mapping, ), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("heading", { name: "Legacy group sync" }), + ).toBeVisible(); + }, }; export const GroupsTabMissingClaims: Story = { @@ -115,6 +137,18 @@ export const RolesTab: Story = { args: { tab: "roles", }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("heading", { name: "Sync field" }), + ).toBeVisible(); + await expect( + canvas.getByRole("heading", { name: "Role mapping" }), + ).toBeVisible(); + await expect( + canvas.queryByRole("heading", { name: "Group mapping" }), + ).not.toBeInTheDocument(); + }, }; export const RolesTabMissingClaims: Story = { From 515896c7315893f1a7a1accf0545eb2f9ac44bb5 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 3 Sep 2026 03:52:21 +0000 Subject: [PATCH 04/11] fix: restore switch to sync field header --- .../IdpSyncPage/IdpGroupSyncForm.tsx | 115 +++++++++--------- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 2 +- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 6b4fa7324ac..668256d6fa4 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -30,6 +30,10 @@ import { SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; +import { + StackLabel, + StackLabelHelperText, +} from "#/components/StackLabel/StackLabel"; import { Switch } from "#/components/Switch/Switch"; import { TableCell, TableRow } from "#/components/Table/Table"; import { isEveryoneGroup } from "#/modules/groups"; @@ -152,57 +156,57 @@ export const IdpGroupSyncForm: FC = ({ If empty, group sync is deactivated. -
-
-
- - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); - }} - className="w-72" - /> -
-
- - { - void form.setFieldValue("regex_filter", event.target.value); +
+
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); + }} + className="w-72" + /> +
+
+ + { + void form.setFieldValue( + "regex_filter", + event.target.value, + ); + }} + className="min-w-40" + /> +
+
- + {(form.errors.field || form.errors.regex_filter) && ( +

+ {form.errors.field || form.errors.regex_filter} +

+ )}
- {(form.errors.field || form.errors.regex_filter) && ( -

- {form.errors.field || form.errors.regex_filter} -

- )} -
-
-
- { void form.setFieldValue( @@ -213,16 +217,17 @@ export const IdpGroupSyncForm: FC = ({ }} /> - } - > - - Auto create missing groups - - - Create groups from the IdP when they do not already exist in - Coder. - - + +
+
Date: Thu, 3 Sep 2026 05:21:17 +0000 Subject: [PATCH 05/11] fix: remove the tabs from `` --- docs/admin/users/idp-sync.md | 8 +- .../tests/organizations/idpGroupSync.spec.ts | 26 +- .../tests/organizations/idpRoleSync.spec.ts | 30 +- .../SettingsHeader/SettingsHeader.stories.tsx | 15 + .../SettingsHeader/SettingsHeader.tsx | 12 +- .../IdpSyncPage/IdpGroupSyncForm.tsx | 505 +++++++++--------- .../IdpSyncPage/IdpRoleSyncForm.tsx | 377 ++++++------- .../IdpSyncPage/IdpSyncPage.tsx | 25 +- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 65 ++- .../IdpSyncPage/IdpSyncPageView.tsx | 73 +-- 10 files changed, 583 insertions(+), 553 deletions(-) diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index 1caf1386fe5..337f5f27703 100644 --- a/docs/admin/users/idp-sync.md +++ b/docs/admin/users/idp-sync.md @@ -62,7 +62,7 @@ group sync for each organization. 1. As an Owner or Organization Admin, go to **Admin settings**, select **Organizations**, then **IdP Sync**: - ![IdP Sync - Group sync settings](../../images/admin/users/organizations/group-sync-empty.png) + ![IdP Sync - Group sync](../../images/admin/users/organizations/group-sync-empty.png) 1. Enter the **Group sync field** and an optional **Regex filter**, then select **Save**. @@ -225,11 +225,9 @@ role sync at the organization level. 1. As an Owner or Organization Admin, go to **Admin settings**, select **Organizations**, then **IdP Sync**. -1. Select the **Role sync settings** tab: + ![IdP Sync - Role sync](../../images/admin/users/organizations/role-sync-empty.png) - ![IdP Sync - Role sync settings](../../images/admin/users/organizations/role-sync-empty.png) - -1. Enter the **Role sync field**, then select **Save**. +1. In **Role sync**, enter the **Role sync field**, then select **Save**. 1. Enter the **IdP role name** and **Coder role**, then **Add IdP role**. diff --git a/site/e2e/tests/organizations/idpGroupSync.spec.ts b/site/e2e/tests/organizations/idpGroupSync.spec.ts index 4d2ab86ec93..7818a2253ae 100644 --- a/site/e2e/tests/organizations/idpGroupSync.spec.ts +++ b/site/e2e/tests/organizations/idpGroupSync.spec.ts @@ -22,7 +22,7 @@ test.describe("IdpGroupSyncPage", () => { }) => { requiresLicense(); const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); @@ -41,7 +41,7 @@ test.describe("IdpGroupSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createGroupSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); @@ -67,7 +67,7 @@ test.describe("IdpGroupSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createGroupSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); @@ -85,21 +85,22 @@ test.describe("IdpGroupSyncPage", () => { test("update sync field", async ({ page }) => { requiresLicense(); const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); - const syncField = page.getByRole("textbox", { + const groupForm = page.getByRole("form", { name: "Group sync" }); + const syncField = groupForm.getByRole("textbox", { name: "Group sync field", }); - const saveButton = page.getByRole("button", { name: /save/i }); + const saveButton = groupForm.getByRole("button", { name: /^save$/i }); await expect(saveButton).toBeDisabled(); await syncField.fill("test-field"); await expect(saveButton).toBeEnabled(); - await page.getByRole("button", { name: /save/i }).click(); + await saveButton.click(); await expect( page.getByText("IdP group sync settings updated."), @@ -109,7 +110,7 @@ test.describe("IdpGroupSyncPage", () => { test("toggle off auto create missing groups", async ({ page }) => { requiresLicense(); const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); @@ -131,11 +132,14 @@ test.describe("IdpGroupSyncPage", () => { requiresLicense(); const org = await createOrganizationWithName(randomName()); await createGroupSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); - const exportButton = page.getByRole("button", { name: /Export Policy/i }); + const groupForm = page.getByRole("form", { name: "Group sync" }); + const exportButton = groupForm.getByRole("button", { + name: /export policy/i, + }); await expect(exportButton).toBeEnabled(); await exportButton.click(); }); @@ -145,7 +149,7 @@ test.describe("IdpGroupSyncPage", () => { const orgName = randomName(); await createOrganizationWithName(orgName); - await page.goto(`/organizations/${orgName}/idp-sync?tab=groups`, { + await page.goto(`/organizations/${orgName}/idp-sync`, { waitUntil: "domcontentloaded", }); diff --git a/site/e2e/tests/organizations/idpRoleSync.spec.ts b/site/e2e/tests/organizations/idpRoleSync.spec.ts index a7e7429e234..8cc2fc39d7b 100644 --- a/site/e2e/tests/organizations/idpRoleSync.spec.ts +++ b/site/e2e/tests/organizations/idpRoleSync.spec.ts @@ -22,7 +22,7 @@ test.describe("IdpRoleSyncPage", () => { page, }) => { const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); @@ -40,7 +40,7 @@ test.describe("IdpRoleSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createRoleSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); @@ -61,7 +61,7 @@ test.describe("IdpRoleSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createRoleSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); const row = page.getByTestId("role-idp-role-1"); @@ -71,7 +71,7 @@ test.describe("IdpRoleSyncPage", () => { row.getByRole("cell", { name: "idp-role-1" }), ).not.toBeVisible(); await expect( - page.getByText("IdP Role sync settings updated."), + page.getByText("IdP role sync settings updated."), ).toBeVisible(); await deleteOrganization(org.name); @@ -79,24 +79,25 @@ test.describe("IdpRoleSyncPage", () => { test("update sync field", async ({ page }) => { const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); - const syncField = page.getByRole("textbox", { + const roleForm = page.getByRole("form", { name: "Role sync" }); + const syncField = roleForm.getByRole("textbox", { name: "Role sync field", }); - const saveButton = page.getByRole("button", { name: /save/i }); + const saveButton = roleForm.getByRole("button", { name: /^save$/i }); await expect(saveButton).toBeDisabled(); await syncField.fill("test-field"); await expect(saveButton).toBeEnabled(); - await page.getByRole("button", { name: /save/i }).click(); + await saveButton.click(); await expect( - page.getByText("IdP Role sync settings updated."), + page.getByText("IdP role sync settings updated."), ).toBeVisible(); await deleteOrganization(org.name); @@ -106,11 +107,14 @@ test.describe("IdpRoleSyncPage", () => { page, }) => { const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + await page.goto(`/organizations/${org.name}/idp-sync`, { waitUntil: "domcontentloaded", }); - const exportButton = page.getByRole("button", { name: /Export Policy/i }); + const roleForm = page.getByRole("form", { name: "Role sync" }); + const exportButton = roleForm.getByRole("button", { + name: /export policy/i, + }); await createRoleSyncSettings(org.id); await expect(exportButton).toBeEnabled(); @@ -121,7 +125,7 @@ test.describe("IdpRoleSyncPage", () => { const orgName = randomName(); await createOrganizationWithName(orgName); - await page.goto(`/organizations/${orgName}/idp-sync?tab=roles`, { + await page.goto(`/organizations/${orgName}/idp-sync`, { waitUntil: "domcontentloaded", }); @@ -166,7 +170,7 @@ test.describe("IdpRoleSyncPage", () => { ).toBeVisible(); await expect( - page.getByText("IdP Role sync settings updated."), + page.getByText("IdP role sync settings updated."), ).toBeVisible(); await deleteOrganization(orgName); diff --git a/site/src/components/SettingsHeader/SettingsHeader.stories.tsx b/site/src/components/SettingsHeader/SettingsHeader.stories.tsx index 1e810c6d844..dbb8429ef59 100644 --- a/site/src/components/SettingsHeader/SettingsHeader.stories.tsx +++ b/site/src/components/SettingsHeader/SettingsHeader.stories.tsx @@ -81,3 +81,18 @@ export const SecondaryHeaderWithDescriptionAndDocsLink: Story = { actions: , }, }; + +export const TertiaryHeaderWithDescription: Story = { + args: { + children: ( + <> + + This is a tertiary header. + + + Use tertiary styling for subsection titles under a secondary header. + + + ), + }, +}; diff --git a/site/src/components/SettingsHeader/SettingsHeader.tsx b/site/src/components/SettingsHeader/SettingsHeader.tsx index cd5015a9bc7..4c3fbe64c38 100644 --- a/site/src/components/SettingsHeader/SettingsHeader.tsx +++ b/site/src/components/SettingsHeader/SettingsHeader.tsx @@ -15,10 +15,13 @@ export const SettingsHeader: FC = ({ className, }) => { return ( -
-
- {children} -
+
+
{children}
{actions}
); @@ -49,6 +52,7 @@ const titleVariants = cva("m-0 flex items-center gap-2 leading-tight", { hierarchy: { primary: "text-3xl font-semibold", secondary: "text-2xl font-medium", + tertiary: "text-xl font-medium", }, }, defaultVariants: { diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 668256d6fa4..4e9bef92cab 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -142,274 +142,287 @@ export const IdpGroupSyncForm: FC = ({ }; return ( -
+
-
- - - Sync field - - - If empty, group sync is deactivated. - - -
-
-
-
- - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); + + } + > + + Group sync + + + Assign groups from IdP claims. + + +
+
+ + + Sync field + + + If empty, group sync is deactivated. + + +
+
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); + }} + className="w-72" + /> +
+
+ + { + void form.setFieldValue( + "regex_filter", + event.target.value, + ); + }} + className="min-w-40" + /> +
+
-
- - { + {(form.errors.field || form.errors.regex_filter) && ( +

+ {form.errors.field || form.errors.regex_filter} +

+ )} +
+
+ + { void form.setFieldValue( - "regex_filter", - event.target.value, + "auto_create_missing_groups", + checked, ); + form.handleSubmit(); }} - className="min-w-40" /> -
+ + +
+
+
+
+ + + Group mapping + + + Map IdP groups to Coder groups. + + +
+
+ + {claimFieldValues ? ( + setIdpGroupName(value ?? "")} + > + + + + + + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + + ) : ( + { + setIdpGroupName(event.target.value); + }} + /> + )} +
+
+ + !isEveryoneGroup(group)) + .map((group) => ({ + label: group.display_name || group.name, + value: group.id, + }))} + hidePlaceholderWhenSelected + placeholder="Select group" + emptyIndicator={ +

+ No more groups to select +

+ } + /> +
+
+
- {(form.errors.field || form.errors.regex_filter) && ( -

- {form.errors.field || form.errors.regex_filter} -

- )} -
-
- - { - void form.setFieldValue( - "auto_create_missing_groups", - checked, - ); - form.handleSubmit(); - }} - /> - -
-
-
-
- - } - > - - Group mapping - - - Map IdP groups to Coder groups. - - -
-
- - {claimFieldValues ? ( - setIdpGroupName(value ?? "")} - > - - + {Object.values(form.errors.mapping || {})} +

+ )} + + {groupSyncSettings?.mapping && + Object.entries(groupSyncSettings.mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpGroup, groups]) => ( + -
- - +
+ {groupSyncSettings?.legacy_group_name_mapping && ( +
+ + + Legacy group sync + + + These settings were configured using environment variables, + and only apply to the default organization. Configure IdP sync + in the UI or CLI so it can apply to any organization.{" "} + + + + + {Object.entries(groupSyncSettings.legacy_group_name_mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpGroup, groupId]) => ( + - - {claimFieldValues - .filter((value) => - value - .toLowerCase() - .includes(comboInputValue.toLowerCase()), - ) - .map((value) => ( - setComboInputValue("")} - > - {value} - - ))} - - - - ) : ( - { - setIdpGroupName(event.target.value); - }} - /> - )} -
-
- - !isEveryoneGroup(group)) - .map((group) => ({ - label: group.display_name || group.name, - value: group.id, - }))} - hidePlaceholderWhenSelected - placeholder="Select group" - emptyIndicator={ -

- No more groups to select -

- } - /> + ))} +
-
-
- -
-
- {form.errors.mapping && ( -

- {Object.values(form.errors.mapping || {})} -

)} - - {groupSyncSettings?.mapping && - Object.entries(groupSyncSettings.mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpGroup, groups]) => ( - - ))} -
- {groupSyncSettings?.legacy_group_name_mapping && ( -
- - - Legacy group sync - - - These settings were configured using environment variables, and - only apply to the default organization. Configure IdP sync in - the UI or CLI so it can apply to any organization.{" "} - - - - - {Object.entries(groupSyncSettings.legacy_group_name_mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpGroup, groupId]) => ( - - ))} - -
- )}
); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx index 2cdab5312fb..1ba2e4da7d3 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -122,201 +122,212 @@ export const IdpRoleSyncForm: FC = ({ }; return ( -
+
-
- - - Sync field - - - If empty, role sync is deactivated. - - -
-
-
- - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); + + } + > + + Role sync + + + Assign roles from IdP claims. + + +
+
+ + + Sync field + + + If empty, role sync is deactivated. + + +
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); + }} + className="w-72" + /> +
+ +
+ {form.errors.field && ( +

+ {form.errors.field} +

+ )} +
+
+
+ + + Role mapping + + + Map IdP roles to Coder roles. + + +
+
+ + {claimFieldValues ? ( + setIdpRoleName(value ?? "")} + > + + + + + + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + + ) : ( + { + setIdpRoleName(event.target.value); + }} + /> + )} +
+
+ + ({ + label: role.display_name || role.name, + value: role.name, + }))} + hidePlaceholderWhenSelected + placeholder="Select role" + emptyIndicator={ +

+ All roles selected +

+ } />
- +
+
+ +
- {form.errors.field && ( + {form.errors.mapping && (

- {form.errors.field} + {Object.values(form.errors.mapping || {})}

)} -
-
-
- - } - > - - Role mapping - - - Map IdP roles to Coder roles. - - -
-
- - {claimFieldValues ? ( - setIdpRoleName(value ?? "")} - > - - - - - + {roleSyncSettings?.mapping && + Object.entries(roleSyncSettings.mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpRole, roles]) => ( + - - {claimFieldValues - .filter((value) => - value - .toLowerCase() - .includes(comboInputValue.toLowerCase()), - ) - .map((value) => ( - setComboInputValue("")} - > - {value} - - ))} - - - - ) : ( - { - setIdpRoleName(event.target.value); - }} - /> - )} -
-
- - ({ - label: role.display_name || role.name, - value: role.name, - }))} - hidePlaceholderWhenSelected - placeholder="Select role" - emptyIndicator={ -

- All roles selected -

- } - /> -
-
-
- -
+ ))} +
- {form.errors.mapping && ( -

- {Object.values(form.errors.mapping || {})} -

- )} - - {roleSyncSettings?.mapping && - Object.entries(roleSyncSettings.mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpRole, roles]) => ( - - ))} -
diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index 90e62183efc..fe33dd586e2 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -1,6 +1,6 @@ import { type FC, useState } from "react"; -import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; -import { useParams, useSearchParams } from "react-router"; +import { useMutation, useQueries, useQueryClient } from "react-query"; +import { useParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { groupsByOrganization } from "#/api/queries/groups"; @@ -54,17 +54,22 @@ const IdpSyncPage: FC = () => { ], }); - const [searchParams] = useSearchParams(); - const tab = searchParams.get("tab") || "groups"; const groupField = groupFieldOverride ?? groupIdpSyncSettingsQuery.data?.field ?? ""; const roleField = roleFieldOverride ?? roleIdpSyncSettingsQuery.data?.field ?? ""; - const field = tab === "groups" ? groupField : roleField; - const fieldValuesQuery = useQuery({ - ...organizationIdpSyncClaimFieldValues(organizationName, field), - enabled: Boolean(field), + const [groupFieldValuesQuery, roleFieldValuesQuery] = useQueries({ + queries: [ + { + ...organizationIdpSyncClaimFieldValues(organizationName, groupField), + enabled: Boolean(groupField), + }, + { + ...organizationIdpSyncClaimFieldValues(organizationName, roleField), + enabled: Boolean(roleField), + }, + ], }); const patchGroupSyncSettingsMutation = useMutation( @@ -134,10 +139,10 @@ const IdpSyncPage: FC = () => { /> ) : ( = { title: "pages/IdpSyncPage", component: IdpSyncPageView, args: { - tab: "groups", groupSyncSettings: MockGroupSyncSettings, roleSyncSettings: MockRoleSyncSettings, - claimFieldValues: [ - ...Object.keys(MockGroupSyncSettings.mapping), - ...Object.keys(MockRoleSyncSettings.mapping), - ], + groupClaimFieldValues: Object.keys(MockGroupSyncSettings.mapping), + roleClaimFieldValues: Object.keys(MockRoleSyncSettings.mapping), groups: [MockGroup, MockGroup2], groupsMap, organization: MockOrganization, @@ -77,17 +74,30 @@ export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - canvas.getByRole("heading", { name: "Sync field" }), + canvas.getByRole("heading", { name: "Group sync", level: 2 }), ).toBeVisible(); await expect( - canvas.getByRole("switch", { name: /auto create missing groups/i }), + canvas.getByRole("heading", { name: "Role sync", level: 2 }), + ).toBeVisible(); + await expect( + canvas.getAllByRole("heading", { name: "Sync field", level: 3 }), + ).toHaveLength(2); + await expect( + canvas.getByRole("heading", { name: "Group mapping", level: 3 }), ).toBeVisible(); await expect( - canvas.getByRole("heading", { name: "Group mapping" }), + canvas.getByRole("heading", { name: "Role mapping", level: 3 }), ).toBeVisible(); + await expect( + canvas.getByRole("switch", { name: /auto create missing groups/i }), + ).toBeVisible(); + await expect( + canvas.getAllByRole("button", { name: /export policy/i }), + ).toHaveLength(2); await expect( canvas.queryByRole("heading", { name: "Legacy group sync" }), ).not.toBeInTheDocument(); + await expect(canvas.queryByRole("tab")).not.toBeInTheDocument(); }, }; @@ -112,51 +122,40 @@ export const MultipleOverflowGroups: Story = { export const WithLegacyMapping: Story = { args: { groupSyncSettings: MockLegacyMappingGroupSyncSettings, - claimFieldValues: Object.keys( + groupClaimFieldValues: Object.keys( MockLegacyMappingGroupSyncSettings.legacy_group_name_mapping, ), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - canvas.getByRole("heading", { name: "Legacy group sync" }), + canvas.getByRole("heading", { name: "Legacy group sync", level: 3 }), ).toBeVisible(); }, }; -export const GroupsTabMissingClaims: Story = { +export const MissingGroupClaims: Story = { args: { - claimFieldValues: [], + groupClaimFieldValues: [], }, play: async ({ canvasElement }) => { await hoverUnknownClaimWarning(canvasElement); }, }; -export const RolesTab: Story = { +export const MissingRoleClaims: Story = { args: { - tab: "roles", + roleClaimFieldValues: [], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - canvas.getByRole("heading", { name: "Sync field" }), - ).toBeVisible(); - await expect( - canvas.getByRole("heading", { name: "Role mapping" }), - ).toBeVisible(); - await expect( - canvas.queryByRole("heading", { name: "Group mapping" }), - ).not.toBeInTheDocument(); - }, -}; - -export const RolesTabMissingClaims: Story = { - args: { - tab: "roles", - claimFieldValues: [], - }, - play: async ({ canvasElement }) => { - await hoverUnknownClaimWarning(canvasElement); + const roleRow = canvas.getByRole("row", { name: /idp-role-1/ }); + const warning = within(roleRow).getByRole("button", { + name: "Unknown claim value", + }); + await userEvent.hover(warning); + await screen.findByRole("tooltip", { + name: /has not be seen in the specified claim field/i, + }); }, }; diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx index 5702dfd14bc..8774b75f8a5 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx @@ -1,5 +1,4 @@ import type { FC } from "react"; -import { useSearchParams } from "react-router"; import type { Group, GroupSyncSettings, @@ -9,20 +8,14 @@ import type { } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "#/components/Tabs/Tabs"; import { IdpGroupSyncForm } from "./IdpGroupSyncForm"; import { IdpRoleSyncForm } from "./IdpRoleSyncForm"; interface IdpSyncPageViewProps { - tab: string; groupSyncSettings: GroupSyncSettings | undefined; roleSyncSettings: RoleSyncSettings | undefined; - claimFieldValues: readonly string[] | undefined; + groupClaimFieldValues: readonly string[] | undefined; + roleClaimFieldValues: readonly string[] | undefined; groups: Group[] | undefined; groupsMap: Map; roles: Role[] | undefined; @@ -35,10 +28,10 @@ interface IdpSyncPageViewProps { } const IdpSyncPageView: FC = ({ - tab, groupSyncSettings, roleSyncSettings, - claimFieldValues, + groupClaimFieldValues, + roleClaimFieldValues, groups, groupsMap, roles, @@ -49,7 +42,6 @@ const IdpSyncPageView: FC = ({ onSubmitGroupSyncSettings, onSubmitRoleSyncSettings, }) => { - const [_, setSearchParams] = useSearchParams(); const groupMappingCount = groupSyncSettings?.mapping ? Object.entries(groupSyncSettings.mapping).length : 0; @@ -65,43 +57,28 @@ const IdpSyncPageView: FC = ({ } return ( -
+
{Boolean(error) && } - { - setSearchParams({ tab: value }); - }} - > - - Group sync settings - Role sync settings - - - - - - - - + +
); }; From 730e4b1ee767d27c40c13a90ac855b826d75db5e Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 3 Sep 2026 06:18:18 +0000 Subject: [PATCH 06/11] fix: restore tabs : ( --- docs/admin/users/idp-sync.md | 6 +- .../tests/organizations/idpGroupSync.spec.ts | 17 +- .../tests/organizations/idpRoleSync.spec.ts | 15 +- .../modules/idpSync/ExportPolicyButton.tsx | 2 +- .../IdpSyncPage/IdpGroupSyncForm.tsx | 505 +++++++++--------- .../IdpSyncPage/IdpRoleSyncForm.tsx | 377 ++++++------- .../IdpSyncPage/IdpSyncPage.tsx | 5 +- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 59 +- .../IdpSyncPage/IdpSyncPageView.tsx | 82 ++- 9 files changed, 535 insertions(+), 533 deletions(-) diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index 337f5f27703..92c08eb8851 100644 --- a/docs/admin/users/idp-sync.md +++ b/docs/admin/users/idp-sync.md @@ -225,9 +225,11 @@ role sync at the organization level. 1. As an Owner or Organization Admin, go to **Admin settings**, select **Organizations**, then **IdP Sync**. - ![IdP Sync - Role sync](../../images/admin/users/organizations/role-sync-empty.png) +1. Select the **Role sync settings** tab: -1. In **Role sync**, enter the **Role sync field**, then select **Save**. + ![IdP Sync - Role sync settings](../../images/admin/users/organizations/role-sync-empty.png) + +1. Enter the **Role sync field**, then select **Save**. 1. Enter the **IdP role name** and **Coder role**, then **Add IdP role**. diff --git a/site/e2e/tests/organizations/idpGroupSync.spec.ts b/site/e2e/tests/organizations/idpGroupSync.spec.ts index 7818a2253ae..3b7fd5e2637 100644 --- a/site/e2e/tests/organizations/idpGroupSync.spec.ts +++ b/site/e2e/tests/organizations/idpGroupSync.spec.ts @@ -22,7 +22,7 @@ test.describe("IdpGroupSyncPage", () => { }) => { requiresLicense(); const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { waitUntil: "domcontentloaded", }); @@ -41,7 +41,7 @@ test.describe("IdpGroupSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createGroupSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { waitUntil: "domcontentloaded", }); @@ -67,7 +67,7 @@ test.describe("IdpGroupSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createGroupSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { waitUntil: "domcontentloaded", }); @@ -85,7 +85,7 @@ test.describe("IdpGroupSyncPage", () => { test("update sync field", async ({ page }) => { requiresLicense(); const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { waitUntil: "domcontentloaded", }); @@ -110,7 +110,7 @@ test.describe("IdpGroupSyncPage", () => { test("toggle off auto create missing groups", async ({ page }) => { requiresLicense(); const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { waitUntil: "domcontentloaded", }); @@ -132,12 +132,11 @@ test.describe("IdpGroupSyncPage", () => { requiresLicense(); const org = await createOrganizationWithName(randomName()); await createGroupSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { waitUntil: "domcontentloaded", }); - const groupForm = page.getByRole("form", { name: "Group sync" }); - const exportButton = groupForm.getByRole("button", { + const exportButton = page.getByRole("button", { name: /export policy/i, }); await expect(exportButton).toBeEnabled(); @@ -149,7 +148,7 @@ test.describe("IdpGroupSyncPage", () => { const orgName = randomName(); await createOrganizationWithName(orgName); - await page.goto(`/organizations/${orgName}/idp-sync`, { + await page.goto(`/organizations/${orgName}/idp-sync?tab=groups`, { waitUntil: "domcontentloaded", }); diff --git a/site/e2e/tests/organizations/idpRoleSync.spec.ts b/site/e2e/tests/organizations/idpRoleSync.spec.ts index 8cc2fc39d7b..400f5083fa0 100644 --- a/site/e2e/tests/organizations/idpRoleSync.spec.ts +++ b/site/e2e/tests/organizations/idpRoleSync.spec.ts @@ -22,7 +22,7 @@ test.describe("IdpRoleSyncPage", () => { page, }) => { const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { waitUntil: "domcontentloaded", }); @@ -40,7 +40,7 @@ test.describe("IdpRoleSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createRoleSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { waitUntil: "domcontentloaded", }); @@ -61,7 +61,7 @@ test.describe("IdpRoleSyncPage", () => { const org = await createOrganizationWithName(randomName()); await createRoleSyncSettings(org.id); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { waitUntil: "domcontentloaded", }); const row = page.getByTestId("role-idp-role-1"); @@ -79,7 +79,7 @@ test.describe("IdpRoleSyncPage", () => { test("update sync field", async ({ page }) => { const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { waitUntil: "domcontentloaded", }); @@ -107,12 +107,11 @@ test.describe("IdpRoleSyncPage", () => { page, }) => { const org = await createOrganizationWithName(randomName()); - await page.goto(`/organizations/${org.name}/idp-sync`, { + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { waitUntil: "domcontentloaded", }); - const roleForm = page.getByRole("form", { name: "Role sync" }); - const exportButton = roleForm.getByRole("button", { + const exportButton = page.getByRole("button", { name: /export policy/i, }); await createRoleSyncSettings(org.id); @@ -125,7 +124,7 @@ test.describe("IdpRoleSyncPage", () => { const orgName = randomName(); await createOrganizationWithName(orgName); - await page.goto(`/organizations/${orgName}/idp-sync`, { + await page.goto(`/organizations/${orgName}/idp-sync?tab=roles`, { waitUntil: "domcontentloaded", }); diff --git a/site/src/modules/idpSync/ExportPolicyButton.tsx b/site/src/modules/idpSync/ExportPolicyButton.tsx index b1f40d83d39..7a586cf66b3 100644 --- a/site/src/modules/idpSync/ExportPolicyButton.tsx +++ b/site/src/modules/idpSync/ExportPolicyButton.tsx @@ -25,7 +25,7 @@ interface ExportPolicyButtonProps { export const ExportPolicyButton: FC = ({ syncSettings, filename, - size, + size = "sm", download = saveAs, }) => { const [isDownloading, setIsDownloading] = useState(false); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 4e9bef92cab..906501a4a70 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -2,11 +2,7 @@ import { useFormik } from "formik"; import { PlusIcon, TrashIcon } from "lucide-react"; import { type FC, type KeyboardEventHandler, useId, useState } from "react"; import * as Yup from "yup"; -import type { - Group, - GroupSyncSettings, - Organization, -} from "#/api/typesGenerated"; +import type { Group, GroupSyncSettings } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { Combobox, @@ -37,7 +33,6 @@ import { import { Switch } from "#/components/Switch/Switch"; import { TableCell, TableRow } from "#/components/Table/Table"; import { isEveryoneGroup } from "#/modules/groups"; -import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; import { IdpUnseenClaimWarning } from "#/modules/idpSync/IdpUnseenClaimWarning"; import { docs } from "#/utils/docs"; import { isUUID } from "#/utils/uuid"; @@ -74,7 +69,6 @@ interface IdpGroupSyncFormProps { groups: Group[]; groupMappingCount: number; legacyGroupMappingCount: number; - organization: Organization; onSubmit: (data: GroupSyncSettings) => void; onSyncFieldChange: (value: string) => void; } @@ -86,7 +80,6 @@ export const IdpGroupSyncForm: FC = ({ legacyGroupMappingCount, groups, groupsMap, - organization, onSubmit, onSyncFieldChange, }) => { @@ -145,284 +138,264 @@ export const IdpGroupSyncForm: FC = ({
- - } - > - - Group sync - - - Assign groups from IdP claims. - - -
-
- - - Sync field - - - If empty, group sync is deactivated. - - -
-
-
-
- - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); - }} - className="w-72" - /> -
-
- - { - void form.setFieldValue( - "regex_filter", - event.target.value, - ); - }} - className="min-w-40" - /> -
- -
- {(form.errors.field || form.errors.regex_filter) && ( -

- {form.errors.field || form.errors.regex_filter} -

- )} -
-
- - { - void form.setFieldValue( - "auto_create_missing_groups", - checked, - ); - form.handleSubmit(); +
+ + + Sync field + + + If empty, group sync is deactivated. + + +
+
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); }} + className="w-72" /> - - -
-
-
-
- - - Group mapping - - - Map IdP groups to Coder groups. - - -
-
- - {claimFieldValues ? ( - setIdpGroupName(value ?? "")} - > - - - - - - - {claimFieldValues - .filter((value) => - value - .toLowerCase() - .includes(comboInputValue.toLowerCase()), - ) - .map((value) => ( - setComboInputValue("")} - > - {value} - - ))} - - - - ) : ( +
+
+ { - setIdpGroupName(event.target.value); + void form.setFieldValue( + "regex_filter", + event.target.value, + ); }} + className="min-w-40" /> - )} -
-
- - !isEveryoneGroup(group)) - .map((group) => ({ - label: group.display_name || group.name, - value: group.id, - }))} - hidePlaceholderWhenSelected - placeholder="Select group" - emptyIndicator={ -

- No more groups to select -

- } - /> -
-
-
+
+ {(form.errors.field || form.errors.regex_filter) && ( +

+ {form.errors.field || form.errors.regex_filter} +

+ )} +
+
+ + { + void form.setFieldValue( + "auto_create_missing_groups", + checked, + ); + form.handleSubmit(); + }} + /> + +
- {form.errors.mapping && ( -

- {Object.values(form.errors.mapping || {})} -

- )} - - {groupSyncSettings?.mapping && - Object.entries(groupSyncSettings.mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpGroup, groups]) => ( - - ))} -
- {groupSyncSettings?.legacy_group_name_mapping && ( -
- - - Legacy group sync - - - These settings were configured using environment variables, - and only apply to the default organization. Configure IdP sync - in the UI or CLI so it can apply to any organization.{" "} - - - - - {Object.entries(groupSyncSettings.legacy_group_name_mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpGroup, groupId]) => ( - +
+ + + Group mapping + + + Map IdP groups to Coder groups. + + +
+
+ + {claimFieldValues ? ( + setIdpGroupName(value ?? "")} + > + + + + + - ))} - + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + + ) : ( + { + setIdpGroupName(event.target.value); + }} + /> + )} +
+
+ + !isEveryoneGroup(group)) + .map((group) => ({ + label: group.display_name || group.name, + value: group.id, + }))} + hidePlaceholderWhenSelected + placeholder="Select group" + emptyIndicator={ +

+ No more groups to select +

+ } + />
+
+
+ +
+
+ {form.errors.mapping && ( +

+ {Object.values(form.errors.mapping || {})} +

)} + + {groupSyncSettings?.mapping && + Object.entries(groupSyncSettings.mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpGroup, groups]) => ( + + ))} +
+ {groupSyncSettings?.legacy_group_name_mapping && ( +
+ + + Legacy group sync + + + These settings were configured using environment variables, and + only apply to the default organization. Configure IdP sync in + the UI or CLI so it can apply to any organization.{" "} + + + + + {Object.entries(groupSyncSettings.legacy_group_name_mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpGroup, groupId]) => ( + + ))} + +
+ )}
); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx index 1ba2e4da7d3..9d3832fe6c2 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -2,11 +2,7 @@ import { useFormik } from "formik"; import { PlusIcon, TrashIcon } from "lucide-react"; import { type FC, type KeyboardEventHandler, useId, useState } from "react"; import * as Yup from "yup"; -import type { - Organization, - Role, - RoleSyncSettings, -} from "#/api/typesGenerated"; +import type { Role, RoleSyncSettings } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { Combobox, @@ -30,7 +26,6 @@ import { } from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; import { TableCell, TableRow } from "#/components/Table/Table"; -import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; import { IdpUnseenClaimWarning } from "#/modules/idpSync/IdpUnseenClaimWarning"; import { IdpMappingTable } from "./IdpMappingTable"; import { IdpPillList } from "./IdpPillList"; @@ -62,7 +57,6 @@ interface IdpRoleSyncFormProps { roleSyncSettings: RoleSyncSettings; claimFieldValues: readonly string[] | undefined; roleMappingCount: number; - organization: Organization; roles: Role[]; onSubmit: (data: RoleSyncSettings) => void; onSyncFieldChange: (value: string) => void; @@ -72,7 +66,6 @@ export const IdpRoleSyncForm: FC = ({ roleSyncSettings, claimFieldValues, roleMappingCount, - organization, roles, onSubmit, onSyncFieldChange, @@ -125,209 +118,191 @@ export const IdpRoleSyncForm: FC = ({
- - } - > - - Role sync - - - Assign roles from IdP claims. - - -
-
- - - Sync field - - - If empty, role sync is deactivated. - - -
-
-
- - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); - }} - className="w-72" - /> -
- -
- {form.errors.field && ( -

- {form.errors.field} -

- )} -
-
-
- - - Role mapping - - - Map IdP roles to Coder roles. - - -
-
- - {claimFieldValues ? ( - setIdpRoleName(value ?? "")} - > - - - - - - - {claimFieldValues - .filter((value) => - value - .toLowerCase() - .includes(comboInputValue.toLowerCase()), - ) - .map((value) => ( - setComboInputValue("")} - > - {value} - - ))} - - - - ) : ( - { - setIdpRoleName(event.target.value); - }} - /> - )} -
-
- - + + + Sync field + + + If empty, role sync is deactivated. + + +
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); }} - className="min-w-60 max-w-3xl" - value={coderRoles} - onChange={setCoderRoles} - options={roles.map((role) => ({ - label: role.display_name || role.name, - value: role.name, - }))} - hidePlaceholderWhenSelected - placeholder="Select role" - emptyIndicator={ -

- All roles selected -

- } + className="w-72" />
-
-
- -
+
- {form.errors.mapping && ( + {form.errors.field && (

- {Object.values(form.errors.mapping || {})} + {form.errors.field}

)} - - {roleSyncSettings?.mapping && - Object.entries(roleSyncSettings.mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpRole, roles]) => ( - +
+
+ + + Role mapping + + + Map IdP roles to Coder roles. + + +
+
+ + {claimFieldValues ? ( + setIdpRoleName(value ?? "")} + > + + + + + - ))} - + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + + ) : ( + { + setIdpRoleName(event.target.value); + }} + /> + )} +
+
+ + ({ + label: role.display_name || role.name, + value: role.name, + }))} + hidePlaceholderWhenSelected + placeholder="Select role" + emptyIndicator={ +

+ All roles selected +

+ } + /> +
+
+
+ +
+ {form.errors.mapping && ( +

+ {Object.values(form.errors.mapping || {})} +

+ )} + + {roleSyncSettings?.mapping && + Object.entries(roleSyncSettings.mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpRole, roles]) => ( + + ))} +
diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index fe33dd586e2..a408354ca4e 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -1,6 +1,6 @@ import { type FC, useState } from "react"; import { useMutation, useQueries, useQueryClient } from "react-query"; -import { useParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { groupsByOrganization } from "#/api/queries/groups"; @@ -54,6 +54,8 @@ const IdpSyncPage: FC = () => { ], }); + const [searchParams] = useSearchParams(); + const tab = searchParams.get("tab") === "roles" ? "roles" : "groups"; const groupField = groupFieldOverride ?? groupIdpSyncSettingsQuery.data?.field ?? ""; const roleField = @@ -139,6 +141,7 @@ const IdpSyncPage: FC = () => { /> ) : ( = { title: "pages/IdpSyncPage", component: IdpSyncPageView, args: { + tab: "groups", groupSyncSettings: MockGroupSyncSettings, roleSyncSettings: MockRoleSyncSettings, groupClaimFieldValues: Object.keys(MockGroupSyncSettings.mapping), @@ -74,30 +75,26 @@ export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - canvas.getByRole("heading", { name: "Group sync", level: 2 }), - ).toBeVisible(); + canvas.getByRole("tab", { name: "Group sync settings" }), + ).toHaveAttribute("aria-selected", "true"); await expect( - canvas.getByRole("heading", { name: "Role sync", level: 2 }), + canvas.getByRole("heading", { name: "Sync field" }), ).toBeVisible(); await expect( - canvas.getAllByRole("heading", { name: "Sync field", level: 3 }), - ).toHaveLength(2); - await expect( - canvas.getByRole("heading", { name: "Group mapping", level: 3 }), + canvas.getByRole("switch", { name: /auto create missing groups/i }), ).toBeVisible(); await expect( - canvas.getByRole("heading", { name: "Role mapping", level: 3 }), + canvas.getByRole("heading", { name: "Group mapping" }), ).toBeVisible(); await expect( - canvas.getByRole("switch", { name: /auto create missing groups/i }), + canvas.getByRole("button", { name: /export policy/i }), ).toBeVisible(); await expect( - canvas.getAllByRole("button", { name: /export policy/i }), - ).toHaveLength(2); + canvas.queryByRole("heading", { name: "Role mapping" }), + ).not.toBeInTheDocument(); await expect( canvas.queryByRole("heading", { name: "Legacy group sync" }), ).not.toBeInTheDocument(); - await expect(canvas.queryByRole("tab")).not.toBeInTheDocument(); }, }; @@ -129,12 +126,12 @@ export const WithLegacyMapping: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - canvas.getByRole("heading", { name: "Legacy group sync", level: 3 }), + canvas.getByRole("heading", { name: "Legacy group sync" }), ).toBeVisible(); }, }; -export const MissingGroupClaims: Story = { +export const GroupsTabMissingClaims: Story = { args: { groupClaimFieldValues: [], }, @@ -143,19 +140,33 @@ export const MissingGroupClaims: Story = { }, }; -export const MissingRoleClaims: Story = { +export const RolesTab: Story = { args: { - roleClaimFieldValues: [], + tab: "roles", }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const roleRow = canvas.getByRole("row", { name: /idp-role-1/ }); - const warning = within(roleRow).getByRole("button", { - name: "Unknown claim value", - }); - await userEvent.hover(warning); - await screen.findByRole("tooltip", { - name: /has not be seen in the specified claim field/i, - }); + await expect( + canvas.getByRole("tab", { name: "Role sync settings" }), + ).toHaveAttribute("aria-selected", "true"); + await expect( + canvas.getByRole("heading", { name: "Sync field" }), + ).toBeVisible(); + await expect( + canvas.getByRole("heading", { name: "Role mapping" }), + ).toBeVisible(); + await expect( + canvas.queryByRole("heading", { name: "Group mapping" }), + ).not.toBeInTheDocument(); + }, +}; + +export const RolesTabMissingClaims: Story = { + args: { + tab: "roles", + roleClaimFieldValues: [], + }, + play: async ({ canvasElement }) => { + await hoverUnknownClaimWarning(canvasElement); }, }; diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx index 8774b75f8a5..68714b9034b 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; +import { useSearchParams } from "react-router"; import type { Group, GroupSyncSettings, @@ -8,10 +9,18 @@ import type { } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "#/components/Tabs/Tabs"; +import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; import { IdpGroupSyncForm } from "./IdpGroupSyncForm"; import { IdpRoleSyncForm } from "./IdpRoleSyncForm"; interface IdpSyncPageViewProps { + tab: string; groupSyncSettings: GroupSyncSettings | undefined; roleSyncSettings: RoleSyncSettings | undefined; groupClaimFieldValues: readonly string[] | undefined; @@ -28,6 +37,7 @@ interface IdpSyncPageViewProps { } const IdpSyncPageView: FC = ({ + tab, groupSyncSettings, roleSyncSettings, groupClaimFieldValues, @@ -42,6 +52,7 @@ const IdpSyncPageView: FC = ({ onSubmitGroupSyncSettings, onSubmitRoleSyncSettings, }) => { + const [_, setSearchParams] = useSearchParams(); const groupMappingCount = groupSyncSettings?.mapping ? Object.entries(groupSyncSettings.mapping).length : 0; @@ -57,28 +68,57 @@ const IdpSyncPageView: FC = ({ } return ( -
+
{Boolean(error) && } - - + { + setSearchParams({ tab: value }); + }} + > + + Group sync settings + Role sync settings + + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+
); }; From 1b835e6dfc16e961ba7f7b7ee447996cb6e29418 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 3 Sep 2026 06:28:07 +0000 Subject: [PATCH 07/11] feat: implement delete confirm dialog --- .../tests/organizations/idpGroupSync.spec.ts | 7 +- .../IdpSyncPage/IdpGroupSyncForm.tsx | 516 +++++++++--------- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 52 +- 3 files changed, 325 insertions(+), 250 deletions(-) diff --git a/site/e2e/tests/organizations/idpGroupSync.spec.ts b/site/e2e/tests/organizations/idpGroupSync.spec.ts index 3b7fd5e2637..50bb0cf7c7d 100644 --- a/site/e2e/tests/organizations/idpGroupSync.spec.ts +++ b/site/e2e/tests/organizations/idpGroupSync.spec.ts @@ -73,7 +73,12 @@ test.describe("IdpGroupSyncPage", () => { const row = page.getByTestId("group-idp-group-1"); await expect(row.getByRole("cell", { name: "idp-group-1" })).toBeVisible(); - await row.getByRole("button", { name: /delete/i }).click(); + await row.getByRole("button", { name: /delete mapping/i }).click(); + const dialog = page.getByRole("dialog", { name: "Delete group mapping" }); + await dialog + .getByLabel("Name of the group mapping to delete") + .fill("idp-group-1"); + await dialog.getByRole("button", { name: /^delete$/i }).click(); await expect( row.getByRole("cell", { name: "idp-group-1" }), ).not.toBeVisible(); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 906501a4a70..cff8c09c7ad 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -13,6 +13,7 @@ import { ComboboxList, ComboboxTrigger, } from "#/components/Combobox/Combobox"; +import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { @@ -97,6 +98,7 @@ export const IdpGroupSyncForm: FC = ({ }); const [idpGroupName, setIdpGroupName] = useState(""); const [coderGroups, setCoderGroups] = useState([]); + const [mappingToDelete, setMappingToDelete] = useState(); const id = useId(); const [comboInputValue, setComboInputValue] = useState(""); const [open, setOpen] = useState(false); @@ -135,269 +137,289 @@ export const IdpGroupSyncForm: FC = ({ }; return ( -
-
-
- - - Sync field - - - If empty, group sync is deactivated. - - -
-
-
-
- - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); + <> + +
+
+ + + Sync field + + + If empty, group sync is deactivated. + + +
+
+
+
+ + { + void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); + }} + className="w-72" + /> +
+
+ + { + void form.setFieldValue( + "regex_filter", + event.target.value, + ); + }} + className="min-w-40" + /> +
+
-
- - { + {(form.errors.field || form.errors.regex_filter) && ( +

+ {form.errors.field || form.errors.regex_filter} +

+ )} +
+
+ + { void form.setFieldValue( - "regex_filter", - event.target.value, + "auto_create_missing_groups", + checked, ); + form.handleSubmit(); }} - className="min-w-40" /> -
- -
- {(form.errors.field || form.errors.regex_filter) && ( -

- {form.errors.field || form.errors.regex_filter} -

- )} -
-
- - { - void form.setFieldValue( - "auto_create_missing_groups", - checked, - ); - form.handleSubmit(); - }} - /> - - -
-
-
-
- - - Group mapping - - - Map IdP groups to Coder groups. - - -
-
- - {claimFieldValues ? ( - setIdpGroupName(value ?? "")} - > - - - - - - - {claimFieldValues - .filter((value) => - value - .toLowerCase() - .includes(comboInputValue.toLowerCase()), - ) - .map((value) => ( - setComboInputValue("")} - > - {value} - - ))} - - - - ) : ( - { - setIdpGroupName(event.target.value); - }} - /> - )} -
-
- - !isEveryoneGroup(group)) - .map((group) => ({ - label: group.display_name || group.name, - value: group.id, - }))} - hidePlaceholderWhenSelected - placeholder="Select group" - emptyIndicator={ -

- No more groups to select -

- } - /> -
-
-
- + +
- {form.errors.mapping && ( -

- {Object.values(form.errors.mapping || {})} -

- )} - - {groupSyncSettings?.mapping && - Object.entries(groupSyncSettings.mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpGroup, groups]) => ( - - ))} - -
- {groupSyncSettings?.legacy_group_name_mapping && (
- Legacy group sync + Group mapping - These settings were configured using environment variables, and - only apply to the default organization. Configure IdP sync in - the UI or CLI so it can apply to any organization.{" "} - + Map IdP groups to Coder groups. - - {Object.entries(groupSyncSettings.legacy_group_name_mapping) - .sort(([a], [b]) => - a.toLowerCase().localeCompare(b.toLowerCase()), - ) - .map(([idpGroup, groupId]) => ( - +
+ + {claimFieldValues ? ( + setIdpGroupName(value ?? "")} + > + + + + + + + {claimFieldValues + .filter((value) => + value + .toLowerCase() + .includes(comboInputValue.toLowerCase()), + ) + .map((value) => ( + setComboInputValue("")} + > + {value} + + ))} + + + + ) : ( + { + setIdpGroupName(event.target.value); + }} /> - ))} + )} +
+
+ + !isEveryoneGroup(group)) + .map((group) => ({ + label: group.display_name || group.name, + value: group.id, + }))} + hidePlaceholderWhenSelected + placeholder="Select group" + emptyIndicator={ +

+ No more groups to select +

+ } + /> +
+
+
+ +
+
+ {form.errors.mapping && ( +

+ {Object.values(form.errors.mapping || {})} +

+ )} + + {groupSyncSettings?.mapping && + Object.entries(groupSyncSettings.mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpGroup, groups]) => ( + setMappingToDelete(idpGroup)} + /> + ))}
- )} -
-
+ {groupSyncSettings?.legacy_group_name_mapping && ( +
+ + + Legacy group sync + + + These settings were configured using environment variables, + and only apply to the default organization. Configure IdP sync + in the UI or CLI so it can apply to any organization.{" "} + + + + + {Object.entries(groupSyncSettings.legacy_group_name_mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpGroup, groupId]) => ( + setMappingToDelete(idpGroup)} + /> + ))} + +
+ )} + + + setMappingToDelete(undefined)} + onConfirm={() => { + if (!mappingToDelete) { + return; + } + void handleDelete(mappingToDelete); + setMappingToDelete(undefined); + }} + /> + ); }; @@ -429,14 +451,12 @@ const GroupRow: FC = ({ diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx index fc0e432fdf0..408e43cc3d7 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, userEvent, within } from "storybook/test"; +import { expect, fn, screen, userEvent, within } from "storybook/test"; import { MockGroup, MockGroup2, @@ -46,6 +46,7 @@ const meta: Meta = { groupsMap, organization: MockOrganization, error: undefined, + onSubmitGroupSyncSettings: fn(), }, }; @@ -98,6 +99,55 @@ export const Default: Story = { }, }; +export const DeleteGroupMapping: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const row = canvas.getByRole("row", { name: /idp-group-1/ }); + const neighbor = canvas.getByRole("row", { name: /idp-group-2/ }); + + await userEvent.click( + within(row).getByRole("button", { + name: "Delete mapping for idp-group-1", + }), + ); + + const dialog = await screen.findByRole("dialog", { + name: "Delete group mapping", + }); + await expect(dialog).toBeVisible(); + await expect(neighbor).toBeVisible(); + await expect( + within(dialog).getByRole("button", { name: /^delete$/i }), + ).toBeDisabled(); + + await userEvent.click( + within(dialog).getByRole("button", { name: "Cancel" }), + ); + await expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await expect(row).toBeVisible(); + await expect(args.onSubmitGroupSyncSettings).not.toHaveBeenCalled(); + + await userEvent.click( + within(row).getByRole("button", { + name: "Delete mapping for idp-group-1", + }), + ); + const confirmDialog = await screen.findByRole("dialog", { + name: "Delete group mapping", + }); + await userEvent.type( + within(confirmDialog).getByLabelText( + "Name of the group mapping to delete", + ), + "idp-group-1", + ); + await userEvent.click( + within(confirmDialog).getByRole("button", { name: /^delete$/i }), + ); + await expect(args.onSubmitGroupSyncSettings).toHaveBeenCalled(); + }, +}; + export const HasError: Story = { args: { error: "This is a test error", From 9b1d6fcbca845b037b21b22750dd29b4f7257a9f Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 3 Sep 2026 06:38:02 +0000 Subject: [PATCH 08/11] fix: resolve wording on dialog --- .../OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index cff8c09c7ad..a083d4b28e3 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -409,7 +409,7 @@ export const IdpGroupSyncForm: FC = ({ confirmLoading={form.isSubmitting} entity="group mapping" name={mappingToDelete ?? ""} - info="Users with that IdP group will no longer be assigned the mapped Coder groups." + info="This mapping is removed from the policy. Group membership is updated the next time a user logs in." onCancel={() => setMappingToDelete(undefined)} onConfirm={() => { if (!mappingToDelete) { From c6af4f8b21e487669027ea89f0e854d4d785d84f Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 4 Sep 2026 16:28:33 +0000 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=A4=96=20fix(site):=20address=20IdP?= =?UTF-8?q?=20sync=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../idpSync/ExportPolicyButton.stories.tsx | 29 ++++++----- .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 24 +++++----- .../IdpSyncPage/IdpGroupSyncForm.tsx | 12 ++--- .../IdpSyncPage/IdpRoleSyncForm.tsx | 12 ++--- .../IdpSyncPage/IdpSyncPage.tsx | 48 ++++++++++--------- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 1 - 6 files changed, 61 insertions(+), 65 deletions(-) diff --git a/site/src/modules/idpSync/ExportPolicyButton.stories.tsx b/site/src/modules/idpSync/ExportPolicyButton.stories.tsx index 5045b200af8..46ee7e7fbe3 100644 --- a/site/src/modules/idpSync/ExportPolicyButton.stories.tsx +++ b/site/src/modules/idpSync/ExportPolicyButton.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type { Mock } from "vitest"; import { MockGroupSyncSettings, MockOrganization, @@ -24,17 +23,17 @@ type Story = StoryObj; export const Default: Story = {}; +const downloadGroupPolicy = fn<(file: Blob, filename: string) => void>(); +const downloadRolePolicy = fn<(file: Blob, filename: string) => void>(); +const downloadOrganizationPolicy = fn<(file: Blob, filename: string) => void>(); + const expectExportedPolicy = async ( canvasElement: HTMLElement, - download: Mock | undefined, + download: typeof downloadGroupPolicy, filename: string, settings: unknown, ) => { const canvas = within(canvasElement); - await expect(download).toBeDefined(); - if (!download) { - return; - } await userEvent.click(canvas.getByRole("button", { name: "Export policy" })); await waitFor(() => @@ -54,12 +53,12 @@ export const ClickExportGroupPolicy: Story = { syncSettings: MockGroupSyncSettings, filename: `${MockOrganization.name}_groups-policy.json`, size: "sm", - download: fn(), + download: downloadGroupPolicy, }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement }) => { await expectExportedPolicy( canvasElement, - args.download as Mock | undefined, + downloadGroupPolicy, `${MockOrganization.name}_groups-policy.json`, MockGroupSyncSettings, ); @@ -71,12 +70,12 @@ export const ClickExportRolePolicy: Story = { syncSettings: MockRoleSyncSettings, filename: `${MockOrganization.name}_roles-policy.json`, size: "sm", - download: fn(), + download: downloadRolePolicy, }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement }) => { await expectExportedPolicy( canvasElement, - args.download as Mock | undefined, + downloadRolePolicy, `${MockOrganization.name}_roles-policy.json`, MockRoleSyncSettings, ); @@ -87,12 +86,12 @@ export const ClickExportOrganizationPolicy: Story = { args: { syncSettings: MockOrganizationSyncSettings, filename: "organizations_policy.json", - download: fn(), + download: downloadOrganizationPolicy, }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement }) => { await expectExportedPolicy( canvasElement, - args.download as Mock | undefined, + downloadOrganizationPolicy, "organizations_policy.json", MockOrganizationSyncSettings, ); diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index 5cf84adfcf0..c539ea7ccdd 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -89,17 +89,19 @@ const IdpOrgSyncPage: FC = () => { onSubmit={async (data) => { const mutation = patchOrganizationSyncSettingsMutation.mutateAsync(data); - toast.promise(mutation, { - loading: "Updating organization IdP sync settings...", - success: "Organization IdP sync settings updated.", - error: (error) => ({ - message: getErrorMessage( - error, - "Failed to update organization IdP sync settings.", - ), - description: getErrorDetail(error), - }), - }); + await toast + .promise(mutation, { + loading: "Updating organization IdP sync settings...", + success: "Organization IdP sync settings updated.", + error: (error) => ({ + message: getErrorMessage( + error, + "Failed to update organization IdP sync settings.", + ), + description: getErrorDetail(error), + }), + }) + .unwrap(); }} error={settingsQuery.error || fieldValuesQuery.error} /> diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index a083d4b28e3..538e4935328 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -327,14 +327,10 @@ export const IdpGroupSyncForm: FC = ({ className="min-w-fit" disabled={!idpGroupName || coderGroups.length === 0} onClick={() => { - const newSyncSettings = { - ...form.values, - mapping: { - ...form.values.mapping, - [idpGroupName]: coderGroups.map((group) => group.value), - }, - }; - void form.setFieldValue("mapping", newSyncSettings.mapping); + void form.setFieldValue("mapping", { + ...form.values.mapping, + [idpGroupName]: coderGroups.map((group) => group.value), + }); form.handleSubmit(); setIdpGroupName(""); setCoderGroups([]); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx index 9d3832fe6c2..021c6529a28 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -262,14 +262,10 @@ export const IdpRoleSyncForm: FC = ({ className="min-w-fit" disabled={!idpRoleName || coderRoles.length === 0} onClick={() => { - const newSyncSettings = { - ...form.values, - mapping: { - ...form.values.mapping, - [idpRoleName]: coderRoles.map((role) => role.value), - }, - }; - void form.setFieldValue("mapping", newSyncSettings.mapping); + void form.setFieldValue("mapping", { + ...form.values.mapping, + [idpRoleName]: coderRoles.map((role) => role.value), + }); form.handleSubmit(); setIdpRoleName(""); setCoderRoles([]); diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index a408354ca4e..3b20f9c8817 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -155,31 +155,35 @@ const IdpSyncPage: FC = () => { error={error} onSubmitGroupSyncSettings={async (data) => { const mutation = patchGroupSyncSettingsMutation.mutateAsync(data); - toast.promise(mutation, { - loading: "Updating IdP group sync settings...", - success: "IdP group sync settings updated.", - error: (error) => ({ - message: getErrorMessage( - error, - "Failed to update IdP group sync settings.", - ), - description: getErrorDetail(error), - }), - }); + await toast + .promise(mutation, { + loading: "Updating IdP group sync settings...", + success: "IdP group sync settings updated.", + error: (error) => ({ + message: getErrorMessage( + error, + "Failed to update IdP group sync settings.", + ), + description: getErrorDetail(error), + }), + }) + .unwrap(); }} onSubmitRoleSyncSettings={async (data) => { const mutation = patchRoleSyncSettingsMutation.mutateAsync(data); - toast.promise(mutation, { - loading: "Updating IdP role sync settings...", - success: "IdP role sync settings updated.", - error: (error) => ({ - message: getErrorMessage( - error, - "Failed to update IdP role sync settings.", - ), - description: getErrorDetail(error), - }), - }); + await toast + .promise(mutation, { + loading: "Updating IdP role sync settings...", + success: "IdP role sync settings updated.", + error: (error) => ({ + message: getErrorMessage( + error, + "Failed to update IdP role sync settings.", + ), + description: getErrorDetail(error), + }), + }) + .unwrap(); }} /> )} diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx index 408e43cc3d7..f6464db21b0 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx @@ -114,7 +114,6 @@ export const DeleteGroupMapping: Story = { const dialog = await screen.findByRole("dialog", { name: "Delete group mapping", }); - await expect(dialog).toBeVisible(); await expect(neighbor).toBeVisible(); await expect( within(dialog).getByRole("button", { name: /^delete$/i }), From 57e0886852c6c1c1f2d8075da4ce93d46ae4d1ff Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Fri, 4 Sep 2026 16:56:22 +0000 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=A4=96=20test(site):=20wait=20for?= =?UTF-8?q?=20Radix=20selects=20to=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DateTimeRangePicker.stories.tsx | 3 +++ .../AddMCPServerPageView.stories.tsx | 3 +++ .../MCPServersPage/MCPServersPage.stories.tsx | 3 +++ .../ModelsPage/components/ModelForm.stories.tsx | 12 ++++++++++++ .../PremiumPage/TrialRequestForm.stories.tsx | 3 +++ .../WorkspaceSettingsPageView.stories.tsx | 3 +++ 6 files changed, 27 insertions(+) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx index 344acfb73b0..e549647d07a 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -199,6 +199,9 @@ export const ApplyCustomRange: Story = { screen.getByRole("combobox", { name: "To AM or PM" }), ); await userEvent.click(await screen.findByRole("option", { name: "AM" })); + await waitFor(() => + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(), + ); await waitFor(() => { expect(applyButton).toBeEnabled(); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 27a6acdde48..968f1feee48 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -59,6 +59,9 @@ export const Default: Story = { canvas.getByRole("combobox", { name: /authentication method/i }), ); await userEvent.click(body.getByRole("option", { name: "OAuth2" })); + await waitFor(() => + expect(body.queryByRole("listbox")).not.toBeInTheDocument(), + ); await expect(canvas.getByLabelText(/client id/i)).toBeInTheDocument(); await userEvent.click(addButton); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 2c04d277017..9049846f867 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -965,6 +965,9 @@ export const AddFailurePreservesEnteredValues: Story = { canvas.getByRole("combobox", { name: /authentication method/i }), ); await userEvent.click(body.getByRole("option", { name: "OAuth2" })); + await waitFor(() => + expect(body.queryByRole("listbox")).not.toBeInTheDocument(), + ); await userEvent.type(canvas.getByLabelText(/client id/i), "client-id"); await userEvent.type(canvas.getByLabelText(/client secret/i), "secret"); await userEvent.click(canvas.getByRole("button", { name: "Add server" })); diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 23aedb38d3b..f5754dc0fde 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -54,6 +54,12 @@ const waitForPriceLoading = async (canvas: ReturnType) => { ); }; +const waitForSelectToClose = async () => { + await waitFor(() => + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(), + ); +}; + const withOrganizationModels = (Story: React.FC) => ( + expect(body.queryByRole("listbox")).not.toBeInTheDocument(), + ); }; export const Default: Story = { diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx index 1bbcd7889cd..2775d019915 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx @@ -40,6 +40,9 @@ export const UpdateAutomaticUpdatesPolicy: Story = { await userEvent.click( await screen.findByRole("option", { name: /always/i }), ); + await waitFor(() => + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(), + ); await userEvent.click(canvas.getByRole("button", { name: /save/i })); From 8e6180633d187b879c11e10b0fc6d64a8e37a980 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 10 Sep 2026 00:55:17 +1000 Subject: [PATCH 11/11] Update site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx Co-authored-by: Tyler --- .../OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 538e4935328..57b4983a058 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -405,7 +405,7 @@ export const IdpGroupSyncForm: FC = ({ confirmLoading={form.isSubmitting} entity="group mapping" name={mappingToDelete ?? ""} - info="This mapping is removed from the policy. Group membership is updated the next time a user logs in." + info="This mapping will be removed from the policy. Group membership is updated the next time a user logs in." onCancel={() => setMappingToDelete(undefined)} onConfirm={() => { if (!mappingToDelete) {