diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index 1caf1386fe5..92c08eb8851 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**. diff --git a/site/e2e/tests/organizations/idpGroupSync.spec.ts b/site/e2e/tests/organizations/idpGroupSync.spec.ts index 4d2ab86ec93..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(); @@ -89,17 +94,18 @@ test.describe("IdpGroupSyncPage", () => { 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."), @@ -135,7 +141,9 @@ test.describe("IdpGroupSyncPage", () => { waitUntil: "domcontentloaded", }); - const exportButton = page.getByRole("button", { name: /Export Policy/i }); + const exportButton = page.getByRole("button", { + name: /export policy/i, + }); await expect(exportButton).toBeEnabled(); await exportButton.click(); }); diff --git a/site/e2e/tests/organizations/idpRoleSync.spec.ts b/site/e2e/tests/organizations/idpRoleSync.spec.ts index a7e7429e234..400f5083fa0 100644 --- a/site/e2e/tests/organizations/idpRoleSync.spec.ts +++ b/site/e2e/tests/organizations/idpRoleSync.spec.ts @@ -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); @@ -83,20 +83,21 @@ test.describe("IdpRoleSyncPage", () => { 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); @@ -110,7 +111,9 @@ test.describe("IdpRoleSyncPage", () => { waitUntil: "domcontentloaded", }); - const exportButton = page.getByRole("button", { name: /Export Policy/i }); + const exportButton = page.getByRole("button", { + name: /export policy/i, + }); await createRoleSyncSettings(org.id); await expect(exportButton).toBeEnabled(); @@ -166,7 +169,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 7380ce31c67..54e5cd0dd67 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/modules/idpSync/ExportPolicyButton.stories.tsx b/site/src/modules/idpSync/ExportPolicyButton.stories.tsx new file mode 100644 index 00000000000..46ee7e7fbe3 --- /dev/null +++ b/site/src/modules/idpSync/ExportPolicyButton.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +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 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: typeof downloadGroupPolicy, + filename: string, + settings: unknown, +) => { + const canvas = within(canvasElement); + + 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: downloadGroupPolicy, + }, + play: async ({ canvasElement }) => { + await expectExportedPolicy( + canvasElement, + downloadGroupPolicy, + `${MockOrganization.name}_groups-policy.json`, + MockGroupSyncSettings, + ); + }, +}; + +export const ClickExportRolePolicy: Story = { + args: { + syncSettings: MockRoleSyncSettings, + filename: `${MockOrganization.name}_roles-policy.json`, + size: "sm", + download: downloadRolePolicy, + }, + play: async ({ canvasElement }) => { + await expectExportedPolicy( + canvasElement, + downloadRolePolicy, + `${MockOrganization.name}_roles-policy.json`, + MockRoleSyncSettings, + ); + }, +}; + +export const ClickExportOrganizationPolicy: Story = { + args: { + syncSettings: MockOrganizationSyncSettings, + filename: "organizations_policy.json", + download: downloadOrganizationPolicy, + }, + play: async ({ canvasElement }) => { + await expectExportedPolicy( + canvasElement, + downloadOrganizationPolicy, + "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..7a586cf66b3 --- /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 = "sm", + 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/AgentsPage/hooks/useConversationEditingState.test.ts b/site/src/pages/AgentsPage/hooks/useConversationEditingState.test.ts index 3956d46a106..ae067830122 100644 --- a/site/src/pages/AgentsPage/hooks/useConversationEditingState.test.ts +++ b/site/src/pages/AgentsPage/hooks/useConversationEditingState.test.ts @@ -395,7 +395,7 @@ describe("useConversationEditingState", () => { getValue: vi.fn().mockReturnValue(""), addFileReference: vi.fn(), getContentParts: vi.fn().mockReturnValue([]), - }; // The hook exposes chatInputRef – assign the mock to it. + }; // The hook exposes chatInputRef, so assign the mock to it. result.current.chatInputRef.current = mockInputRef; await act(async () => { @@ -416,7 +416,7 @@ describe("useConversationEditingState", () => { localStorage.setItem(`${draftInputStorageKeyPrefix}${chatA}`, "draft A"); localStorage.setItem(`${draftInputStorageKeyPrefix}${chatB}`, "draft B"); - // Each chatID should initialize with its own draft — this is + // Each chatID should initialize with its own draft. This is // what the key={agentId} wrapper guarantees at the component // level (a new chatID means a full remount). const hookA = renderEditing(chatA); @@ -596,7 +596,7 @@ describe("useConversationEditingState", () => { expect(result.current.initialEditorState).toBeUndefined(); expect(result.current.editorInitialValue).toBe("old message text"); - // Cancel — should restore both plain text and serialized state. + // Cancel should restore both plain text and serialized state. act(() => { result.current.handleCancelHistoryEdit(); }); 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..c539ea7ccdd 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,23 @@ 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( - error, - "Failed to update organization IdP sync settings.", - ), - { + const mutation = + patchOrganizationSyncSettingsMutation.mutateAsync(data); + 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/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..57b4983a058 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -1,12 +1,8 @@ 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 { - Group, - GroupSyncSettings, - Organization, -} from "#/api/typesGenerated"; +import type { Group, GroupSyncSettings } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { Combobox, @@ -17,32 +13,30 @@ import { ComboboxList, ComboboxTrigger, } from "#/components/Combobox/Combobox"; -import { - HelpPopover, - HelpPopoverContent, - HelpPopoverIconTrigger, - HelpPopoverText, - HelpPopoverTitle, -} from "#/components/HelpPopover/HelpPopover"; +import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; -import { Link } from "#/components/Link/Link"; import { MultiSelectCombobox, type Option, } from "#/components/MultiSelectCombobox/MultiSelectCombobox"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderDocsLink, + 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 { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; import { isEveryoneGroup } from "#/modules/groups"; +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"; @@ -76,7 +70,6 @@ interface IdpGroupSyncFormProps { groups: Group[]; groupMappingCount: number; legacyGroupMappingCount: number; - organization: Organization; onSubmit: (data: GroupSyncSettings) => void; onSyncFieldChange: (value: string) => void; } @@ -88,7 +81,6 @@ export const IdpGroupSyncForm: FC = ({ legacyGroupMappingCount, groups, groupsMap, - organization, onSubmit, onSyncFieldChange, }) => { @@ -106,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); @@ -144,226 +137,249 @@ export const IdpGroupSyncForm: FC = ({ }; return ( -
-
-
- -
-
-
-
- - - { - void form.setFieldValue("field", event.target.value); - onSyncFieldChange(event.target.value); - }} - className="w-72" - /> -
- { - void form.setFieldValue("regex_filter", 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( + "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 +

+ } /> +
+
+
-

- If empty, group sync is deactivated -

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

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

- ))} -
-
- - { - void form.setFieldValue("auto_create_missing_groups", checked); - form.handleSubmit(); - }} - /> - - - - - -
-
-
- - {claimFieldValues ? ( - setIdpGroupName(value ?? "")} - > - - - - - - - {claimFieldValues - .filter((value) => - value - .toLowerCase() - .includes(comboInputValue.toLowerCase()), - ) - .map((value) => ( - setComboInputValue("")} - > - {value} - - ))} - - - - ) : ( - { - setIdpGroupName(event.target.value); - }} - /> )} + + {groupSyncSettings?.mapping && + Object.entries(groupSyncSettings.mapping) + .sort(([a], [b]) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ) + .map(([idpGroup, groups]) => ( + setMappingToDelete(idpGroup)} + /> + ))} +
-
- - !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]) => @@ -375,15 +391,31 @@ export const IdpGroupSyncForm: FC = ({ idpGroup={idpGroup} exists={claimFieldValues?.includes(idpGroup)} coderGroup={getGroupNames([groupId])} - onDelete={handleDelete} + onDelete={() => setMappingToDelete(idpGroup)} /> ))}
)} -
-
- +
+ + setMappingToDelete(undefined)} + onConfirm={() => { + if (!mappingToDelete) { + return; + } + void handleDelete(mappingToDelete); + setMappingToDelete(undefined); + }} + /> + ); }; @@ -405,23 +437,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 && }
@@ -431,56 +447,14 @@ 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 ( -

-
- 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 403c9256afb..021c6529a28 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -1,12 +1,8 @@ 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 { - Organization, - Role, - RoleSyncSettings, -} from "#/api/typesGenerated"; +import type { Role, RoleSyncSettings } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { Combobox, @@ -23,14 +19,14 @@ 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 { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { ExportPolicyButton } from "./ExportPolicyButton"; +import { IdpUnseenClaimWarning } from "#/modules/idpSync/IdpUnseenClaimWarning"; import { IdpMappingTable } from "./IdpMappingTable"; import { IdpPillList } from "./IdpPillList"; @@ -61,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; @@ -71,7 +66,6 @@ export const IdpRoleSyncForm: FC = ({ roleSyncSettings, claimFieldValues, roleMappingCount, - organization, roles, onSubmit, onSyncFieldChange, @@ -121,35 +115,35 @@ export const IdpRoleSyncForm: FC = ({ }; return ( -
+
-
- -
-
- -
-
- { - 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.handleSubmit(); + setIdpRoleName(""); + setCoderRoles([]); + }} + > + + + + Add IdP role + +
+ {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]) => ( - - ))} -
); @@ -326,23 +323,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..3b20f9c8817 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -1,5 +1,5 @@ -import { type FC, useEffect, useState } from "react"; -import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; +import { type FC, useState } from "react"; +import { useMutation, useQueries, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; @@ -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,29 +54,24 @@ 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 field = tab === "groups" ? groupField : roleField; + const tab = searchParams.get("tab") === "roles" ? "roles" : "groups"; + const groupField = + groupFieldOverride ?? groupIdpSyncSettingsQuery.data?.field ?? ""; + const roleField = + roleFieldOverride ?? roleIdpSyncSettingsQuery.data?.field ?? ""; - 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( @@ -149,43 +144,46 @@ const IdpSyncPage: FC = () => { tab={tab} groupSyncSettings={groupIdpSyncSettingsQuery.data} roleSyncSettings={roleIdpSyncSettingsQuery.data} - claimFieldValues={fieldValuesQuery.data} + groupClaimFieldValues={groupFieldValuesQuery.data} + roleClaimFieldValues={roleFieldValuesQuery.data} groups={groupsQuery.data} 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); - 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) => { - try { - await patchRoleSyncSettingsMutation.mutateAsync(data); - toast.success("IdP Role sync settings updated."); - } catch (error) { - toast.error( - getErrorMessage( - error, - "Failed to update IdP role sync settings.", - ), - { + const mutation = patchRoleSyncSettingsMutation.mutateAsync(data); + 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 dca9767d0eb..f6464db21b0 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 { expect, fn, 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, @@ -25,14 +40,13 @@ const meta: Meta = { 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, error: undefined, + onSubmitGroupSyncSettings: fn(), }, }; @@ -58,7 +72,80 @@ export const Empty: Story = { }, }; -export const Default: Story = {}; +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("tab", { name: "Group sync settings" }), + ).toHaveAttribute("aria-selected", "true"); + await expect( + canvas.getByRole("heading", { name: "Sync field" }), + ).toBeVisible(); + await expect( + canvas.getByRole("switch", { name: /auto create missing groups/i }), + ).toBeVisible(); + await expect( + canvas.getByRole("heading", { name: "Group mapping" }), + ).toBeVisible(); + await expect( + canvas.getByRole("button", { name: /export policy/i }), + ).toBeVisible(); + await expect( + canvas.queryByRole("heading", { name: "Role mapping" }), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByRole("heading", { name: "Legacy group sync" }), + ).not.toBeInTheDocument(); + }, +}; + +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(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: { @@ -81,21 +168,24 @@ 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" }), + ).toBeVisible(); + }, }; export const GroupsTabMissingClaims: Story = { args: { - claimFieldValues: [], + groupClaimFieldValues: [], }, 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); }, }; @@ -103,17 +193,29 @@ export const RolesTab: Story = { args: { tab: "roles", }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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", - claimFieldValues: [], + roleClaimFieldValues: [], }, 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); }, }; diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx index 5702dfd14bc..68714b9034b 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx @@ -15,6 +15,7 @@ import { TabsList, TabsTrigger, } from "#/components/Tabs/Tabs"; +import { ExportPolicyButton } from "#/modules/idpSync/ExportPolicyButton"; import { IdpGroupSyncForm } from "./IdpGroupSyncForm"; import { IdpRoleSyncForm } from "./IdpRoleSyncForm"; @@ -22,7 +23,8 @@ 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; @@ -38,7 +40,8 @@ const IdpSyncPageView: FC = ({ tab, groupSyncSettings, roleSyncSettings, - claimFieldValues, + groupClaimFieldValues, + roleClaimFieldValues, groups, groupsMap, roles, @@ -77,29 +80,43 @@ const IdpSyncPageView: FC = ({ Group sync settings Role sync settings - - + +
+
+ +
+ +
- - + +
+
+ +
+ +