diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 96a2e37260767..902485b7b15b6 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -81,13 +81,49 @@ export const createOrganizationSyncSettings = async () => { "fbd2116a-8961-4954-87ae-e4575bd29ce0", "13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2", ], - "idp-org-2": ["fbd2116a-8961-4954-87ae-e4575bd29ce0"], + "idp-org-2": ["6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b"], }, organization_assign_default: true, }); return settings; }; +export const createGroupSyncSettings = async (orgId: string) => { + const settings = await API.patchGroupIdpSyncSettings( + { + field: "group-field-test", + mapping: { + "idp-group-1": [ + "fbd2116a-8961-4954-87ae-e4575bd29ce0", + "13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2", + ], + "idp-group-2": ["6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b"], + }, + regex_filter: "@[a-zA-Z0-9]+", + auto_create_missing_groups: true, + }, + orgId, + ); + return settings; +}; + +export const createRoleSyncSettings = async (orgId: string) => { + const settings = await API.patchRoleIdpSyncSettings( + { + field: "role-field-test", + mapping: { + "idp-role-1": [ + "fbd2116a-8961-4954-87ae-e4575bd29ce0", + "13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2", + ], + "idp-role-2": ["6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b"], + }, + }, + orgId, + ); + return settings; +}; + export const createCustomRole = async ( orgId: string, name: string, diff --git a/site/e2e/tests/deployment/idpOrgSync.spec.ts b/site/e2e/tests/deployment/idpOrgSync.spec.ts index 231d15dab15c5..c8e6b5a33b17f 100644 --- a/site/e2e/tests/deployment/idpOrgSync.spec.ts +++ b/site/e2e/tests/deployment/idpOrgSync.spec.ts @@ -16,6 +16,22 @@ test.beforeEach(async ({ page }) => { }); test.describe("IdpOrgSyncPage", () => { + test("show empty table when no org mappings are present", async ({ + page, + }) => { + requiresLicense(); + await page.goto("/deployment/idp-org-sync", { + waitUntil: "domcontentloaded", + }); + + await expect( + page.getByRole("row", { name: "idp-org-1" }), + ).not.toBeVisible(); + await expect( + page.getByRole("heading", { name: "No organization mappings" }), + ).toBeVisible(); + }); + test("add new IdP organization mapping with API", async ({ page }) => { requiresLicense(); @@ -29,14 +45,14 @@ test.describe("IdpOrgSyncPage", () => { page.getByRole("switch", { name: "Assign Default Organization" }), ).toBeChecked(); - await expect(page.getByText("idp-org-1")).toBeVisible(); + await expect(page.getByRole("row", { name: "idp-org-1" })).toBeVisible(); await expect( - page.getByText("fbd2116a-8961-4954-87ae-e4575bd29ce0").first(), + page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }), ).toBeVisible(); - await expect(page.getByText("idp-org-2")).toBeVisible(); + await expect(page.getByRole("row", { name: "idp-org-2" })).toBeVisible(); await expect( - page.getByText("fbd2116a-8961-4954-87ae-e4575bd29ce0").last(), + page.getByRole("row", { name: "6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b" }), ).toBeVisible(); }); @@ -47,12 +63,12 @@ test.describe("IdpOrgSyncPage", () => { waitUntil: "domcontentloaded", }); - await expect(page.getByText("idp-org-1")).toBeVisible(); - await page - .getByRole("button", { name: /delete/i }) - .first() - .click(); - await expect(page.getByText("idp-org-1")).not.toBeVisible(); + const row = page.getByTestId("idp-org-idp-org-1"); + await expect(row.getByRole("cell", { name: "idp-org-1" })).toBeVisible(); + await row.getByRole("button", { name: /delete/i }).click(); + await expect( + row.getByRole("cell", { name: "idp-org-1" }), + ).not.toBeVisible(); await expect( page.getByText("Organization sync settings updated."), ).toBeVisible(); @@ -67,7 +83,7 @@ test.describe("IdpOrgSyncPage", () => { const syncField = page.getByRole("textbox", { name: "Organization sync field", }); - const saveButton = page.getByRole("button", { name: /save/i }).first(); + const saveButton = page.getByRole("button", { name: /save/i }); await expect(saveButton).toBeDisabled(); @@ -154,8 +170,10 @@ test.describe("IdpOrgSyncPage", () => { // Verify new mapping appears in table const newRow = page.getByTestId("idp-org-new-idp-org"); await expect(newRow).toBeVisible(); - await expect(newRow.getByText("new-idp-org")).toBeVisible(); - await expect(newRow.getByText(orgName)).toBeVisible(); + await expect( + newRow.getByRole("cell", { name: "new-idp-org" }), + ).toBeVisible(); + await expect(newRow.getByRole("cell", { name: orgName })).toBeVisible(); await expect( page.getByText("Organization sync settings updated."), diff --git a/site/e2e/tests/organizations/idpGroupSync.spec.ts b/site/e2e/tests/organizations/idpGroupSync.spec.ts new file mode 100644 index 0000000000000..2ea9d02388b72 --- /dev/null +++ b/site/e2e/tests/organizations/idpGroupSync.spec.ts @@ -0,0 +1,184 @@ +import { expect, test } from "@playwright/test"; +import { + createGroupSyncSettings, + createOrganizationWithName, + deleteOrganization, + setupApiCalls, +} from "../../api"; +import { randomName, requiresLicense } from "../../helpers"; +import { login } from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; + +test.beforeEach(async ({ page }) => { + beforeCoderTest(page); + await login(page); + await setupApiCalls(page); +}); + +test.describe("IdpGroupSyncPage", () => { + test("show empty table when no group mappings are present", async ({ + page, + }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + waitUntil: "domcontentloaded", + }); + + await expect( + page.getByRole("row", { name: "idp-group-1" }), + ).not.toBeVisible(); + await expect( + page.getByRole("heading", { name: "No group mappings" }), + ).toBeVisible(); + + await deleteOrganization(org.name); + }); + + test("add new IdP group mapping with API", async ({ page }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await createGroupSyncSettings(org.id); + + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + waitUntil: "domcontentloaded", + }); + + await expect( + page.getByRole("switch", { name: "Auto create missing groups" }), + ).toBeChecked(); + + await expect(page.getByRole("row", { name: "idp-group-1" })).toBeVisible(); + await expect( + page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }), + ).toBeVisible(); + + await expect(page.getByRole("row", { name: "idp-group-2" })).toBeVisible(); + await expect( + page.getByRole("row", { name: "6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b" }), + ).toBeVisible(); + + await deleteOrganization(org.name); + }); + + test("delete a IdP group to coder group mapping row", async ({ page }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await createGroupSyncSettings(org.id); + + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + waitUntil: "domcontentloaded", + }); + + 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 expect( + row.getByRole("cell", { name: "idp-group-1" }), + ).not.toBeVisible(); + await expect( + page.getByText("IdP Group sync settings updated."), + ).toBeVisible(); + }); + + test("update sync field", async ({ page }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + waitUntil: "domcontentloaded", + }); + + const syncField = page.getByRole("textbox", { + name: "Group sync field", + }); + const saveButton = page.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 expect( + page.getByText("IdP Group sync settings updated."), + ).toBeVisible(); + }); + + 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`, { + waitUntil: "domcontentloaded", + }); + + const toggle = page.getByRole("switch", { + name: "Auto create missing groups", + }); + await toggle.click(); + + await expect( + page.getByText("IdP Group sync settings updated."), + ).toBeVisible(); + + await expect(toggle).toBeChecked(); + }); + + test("export policy button is enabled when sync settings are present", async ({ + page, + }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await createGroupSyncSettings(org.id); + await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, { + waitUntil: "domcontentloaded", + }); + + const exportButton = page.getByRole("button", { name: /Export Policy/i }); + await expect(exportButton).toBeEnabled(); + await exportButton.click(); + }); + + test("add new IdP group mapping with UI", async ({ page }) => { + requiresLicense(); + const orgName = randomName(); + await createOrganizationWithName(orgName); + + await page.goto(`/organizations/${orgName}/idp-sync?tab=groups`, { + waitUntil: "domcontentloaded", + }); + + const idpOrgInput = page.getByLabel("IdP group name"); + const orgSelector = page.getByPlaceholder("Select group"); + const addButton = page.getByRole("button", { + name: /Add IdP group/i, + }); + + await expect(addButton).toBeDisabled(); + + await idpOrgInput.fill("new-idp-group"); + + // Select Coder organization from combobox + await orgSelector.click(); + await page.getByRole("option", { name: /Everyone/i }).click(); + + // Add button should now be enabled + await expect(addButton).toBeEnabled(); + + await addButton.click(); + + // Verify new mapping appears in table + const newRow = page.getByTestId("group-new-idp-group"); + await expect(newRow).toBeVisible(); + await expect( + newRow.getByRole("cell", { name: "new-idp-group" }), + ).toBeVisible(); + await expect(newRow.getByRole("cell", { name: "Everyone" })).toBeVisible(); + + await expect( + page.getByText("IdP Group sync settings updated."), + ).toBeVisible(); + + await deleteOrganization(orgName); + }); +}); diff --git a/site/e2e/tests/organizations/idpRoleSync.spec.ts b/site/e2e/tests/organizations/idpRoleSync.spec.ts new file mode 100644 index 0000000000000..3374151a85b56 --- /dev/null +++ b/site/e2e/tests/organizations/idpRoleSync.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from "@playwright/test"; +import { + createOrganizationWithName, + createRoleSyncSettings, + deleteOrganization, + setupApiCalls, +} from "../../api"; +import { randomName, requiresLicense } from "../../helpers"; +import { login } from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; + +test.beforeEach(async ({ page }) => { + beforeCoderTest(page); + await login(page); + await setupApiCalls(page); +}); + +test.describe("IdpRoleSyncPage", () => { + test("show empty table when no role mappings are present", async ({ + page, + }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + waitUntil: "domcontentloaded", + }); + + await expect( + page.getByRole("row", { name: "idp-role-1" }), + ).not.toBeVisible(); + await expect( + page.getByRole("heading", { name: "No role mappings" }), + ).toBeVisible(); + + await deleteOrganization(org.name); + }); + + test("add new IdP role mapping with API", async ({ page }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await createRoleSyncSettings(org.id); + + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + waitUntil: "domcontentloaded", + }); + + await expect(page.getByRole("row", { name: "idp-role-1" })).toBeVisible(); + await expect( + page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }), + ).toBeVisible(); + + await expect(page.getByRole("row", { name: "idp-role-2" })).toBeVisible(); + await expect( + page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }), + ).toBeVisible(); + + await deleteOrganization(org.name); + }); + + test("delete a IdP role to coder role mapping row", async ({ page }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await createRoleSyncSettings(org.id); + + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + waitUntil: "domcontentloaded", + }); + const row = page.getByTestId("role-idp-role-1"); + await expect(row.getByRole("cell", { name: "idp-role-1" })).toBeVisible(); + await row.getByRole("button", { name: /delete/i }).click(); + await expect( + row.getByRole("cell", { name: "idp-role-1" }), + ).not.toBeVisible(); + await expect( + page.getByText("IdP Role sync settings updated."), + ).toBeVisible(); + + await deleteOrganization(org.name); + }); + + test("update sync field", async ({ page }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + waitUntil: "domcontentloaded", + }); + + const syncField = page.getByRole("textbox", { + name: "Role sync field", + }); + const saveButton = page.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 expect( + page.getByText("IdP Role sync settings updated."), + ).toBeVisible(); + + await deleteOrganization(org.name); + }); + + test("export policy button is enabled when sync settings are present", async ({ + page, + }) => { + requiresLicense(); + const org = await createOrganizationWithName(randomName()); + await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, { + waitUntil: "domcontentloaded", + }); + + const exportButton = page.getByRole("button", { name: /Export Policy/i }); + await createRoleSyncSettings(org.id); + + await expect(exportButton).toBeEnabled(); + await exportButton.click(); + }); + + test("add new IdP role mapping with UI", async ({ page }) => { + requiresLicense(); + const orgName = randomName(); + await createOrganizationWithName(orgName); + + await page.goto(`/organizations/${orgName}/idp-sync?tab=roles`, { + waitUntil: "domcontentloaded", + }); + + const idpOrgInput = page.getByLabel("IdP role name"); + const roleSelector = page.getByPlaceholder("Select role"); + const addButton = page.getByRole("button", { + name: /Add IdP role/i, + }); + + await expect(addButton).toBeDisabled(); + + await idpOrgInput.fill("new-idp-role"); + + // Select Coder role from combobox + await roleSelector.click(); + await page.getByRole("option", { name: /Organization Admin/i }).click(); + + // Add button should now be enabled + await expect(addButton).toBeEnabled(); + + await addButton.click(); + + // Verify new mapping appears in table + const newRow = page.getByTestId("role-new-idp-role"); + await expect(newRow).toBeVisible(); + await expect( + newRow.getByRole("cell", { name: "new-idp-role" }), + ).toBeVisible(); + await expect( + newRow.getByRole("cell", { name: "organization-admin" }), + ).toBeVisible(); + + await expect( + page.getByText("IdP Role sync settings updated."), + ).toBeVisible(); + + await deleteOrganization(orgName); + }); +}); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ac4ef4a1ca340..26491efb10565 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -733,6 +733,36 @@ class ApiMethods { return response.data; }; + /** + * @param data + * @param organization Can be the organization's ID or name + */ + patchGroupIdpSyncSettings = async ( + data: TypesGen.GroupSyncSettings, + organization: string, + ) => { + const response = await this.axios.patch( + `/api/v2/organizations/${organization}/settings/idpsync/groups`, + data, + ); + return response.data; + }; + + /** + * @param data + * @param organization Can be the organization's ID or name + */ + patchRoleIdpSyncSettings = async ( + data: TypesGen.RoleSyncSettings, + organization: string, + ) => { + const response = await this.axios.patch( + `/api/v2/organizations/${organization}/settings/idpsync/roles`, + data, + ); + return response.data; + }; + /** * @param organization Can be the organization's ID or name */ diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index c3f5a4ebd3ced..0cc8168243c16 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -2,6 +2,8 @@ import { API } from "api/api"; import type { AuthorizationResponse, CreateOrganizationRequest, + GroupSyncSettings, + RoleSyncSettings, UpdateOrganizationRequest, } from "api/typesGenerated"; import type { QueryClient } from "react-query"; @@ -156,6 +158,18 @@ export const groupIdpSyncSettings = (organization: string) => { }; }; +export const patchGroupSyncSettings = ( + organization: string, + queryClient: QueryClient, +) => { + return { + mutationFn: (request: GroupSyncSettings) => + API.patchGroupIdpSyncSettings(request, organization), + onSuccess: async () => + await queryClient.invalidateQueries(groupIdpSyncSettings(organization)), + }; +}; + export const getRoleIdpSyncSettingsKey = (organization: string) => [ "organizations", organization, @@ -169,6 +183,20 @@ export const roleIdpSyncSettings = (organization: string) => { }; }; +export const patchRoleSyncSettings = ( + organization: string, + queryClient: QueryClient, +) => { + return { + mutationFn: (request: RoleSyncSettings) => + API.patchRoleIdpSyncSettings(request, organization), + onSuccess: async () => + await queryClient.invalidateQueries( + getRoleIdpSyncSettingsKey(organization), + ), + }; +}; + /** * Fetch permissions for a single organization. * diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index bc25b0c077017..93e1a479aa6cc 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -10,10 +10,10 @@ import { cn } from "utils/cn"; export const buttonVariants = cva( `inline-flex items-center justify-center gap-1 whitespace-nowrap border-solid rounded-md transition-colors min-w-20 - text-sm font-semibold font-medium cursor-pointer no-underline + text-sm font-semibold font-medium cursor-pointer no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:pointer-events-none disabled:text-content-disabled - [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-[2px]`, + [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5`, { variants: { variant: { @@ -30,6 +30,7 @@ export const buttonVariants = cva( size: { lg: "h-10 px-3 py-2 [&_svg]:size-icon-lg", sm: "h-[30px] px-2 py-1.5 text-xs [&_svg]:size-icon-sm", + icon: "h-[30px] min-w-[30px] px-1 py-1.5 [&_svg]:size-icon-sm", }, }, defaultVariants: { diff --git a/site/src/components/Input/Input.tsx b/site/src/components/Input/Input.tsx index f1abd6ecc949e..b50d6415a8983 100644 --- a/site/src/components/Input/Input.tsx +++ b/site/src/components/Input/Input.tsx @@ -13,7 +13,7 @@ export const Input = forwardRef< s.fixed); return ( @@ -454,7 +458,7 @@ export const MultiSelectCombobox = forwardRef< {/* biome-ignore lint/a11y/useKeyWithClickEvents: onKeyDown is not needed here */}
- +
diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx index ebeaa762674ad..bdb5aa063da69 100644 --- a/site/src/components/Tabs/Tabs.tsx +++ b/site/src/components/Tabs/Tabs.tsx @@ -1,8 +1,8 @@ -import { type Interpolation, type Theme, useTheme } from "@emotion/react"; import { type FC, type HTMLAttributes, createContext, useContext } from "react"; import { Link, type LinkProps } from "react-router-dom"; +import { cn } from "utils/cn"; -export const TAB_PADDING_Y = 12; +// Keeping this for now because of a workaround in WorkspaceBUildPageView export const TAB_PADDING_X = 16; type TabsContextValue = { @@ -13,15 +13,16 @@ const TabsContext = createContext(undefined); type TabsProps = HTMLAttributes & TabsContextValue; -export const Tabs: FC = ({ active, ...htmlProps }) => { - const theme = useTheme(); - +export const Tabs: FC = ({ className, active, ...htmlProps }) => { return (
@@ -31,16 +32,7 @@ export const Tabs: FC = ({ active, ...htmlProps }) => { type TabsListProps = HTMLAttributes; export const TabsList: FC = (props) => { - return ( -
- ); + return
; }; type TabLinkProps = LinkProps & { @@ -59,37 +51,15 @@ export const TabLink: FC = ({ value, ...linkProps }) => { return ( ); }; - -const styles = { - tabLink: (theme) => ({ - textDecoration: "none", - color: theme.palette.text.secondary, - fontSize: 14, - display: "block", - padding: `${TAB_PADDING_Y}px ${TAB_PADDING_X}px`, - fontWeight: 500, - lineHeight: "1", - - "&:hover": { - color: theme.palette.text.primary, - }, - }), - activeTabLink: (theme) => ({ - color: theme.palette.text.primary, - position: "relative", - - "&:before": { - content: '""', - left: 0, - bottom: -1, - height: 1, - width: "100%", - background: theme.palette.primary.main, - position: "absolute", - }, - }), -} satisfies Record>; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index 13f405affc7f3..d08b3aac4ab1a 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -6,9 +6,9 @@ import { import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { displayError } from "components/GlobalSnackbar/utils"; import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { Link } from "components/Link/Link"; import { Loader } from "components/Loader/Loader"; import { Paywall } from "components/Paywall/Paywall"; -import { SquareArrowOutUpRight } from "lucide-react"; import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { type FC, useEffect } from "react"; @@ -62,13 +62,9 @@ export const IdpOrgSyncPage: FC = () => {

Automatically assign users to an organization based on their IdP claims. - + View docs - - +

diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx index ae90e26c3aa7c..7ed1b85e8c9dd 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx @@ -28,15 +28,18 @@ import { } from "components/HelpTooltip/HelpTooltip"; 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 { Spinner } from "components/Spinner/Spinner"; import { Switch } from "components/Switch/Switch"; import { useFormik } from "formik"; -import { Plus, SquareArrowOutUpRight, Trash } from "lucide-react"; -import { type FC, useState } from "react"; +import { Plus, Trash } from "lucide-react"; +import { type FC, useId, useState } from "react"; import { docs } from "utils/docs"; +import { isUUID } from "utils/uuid"; import * as Yup from "yup"; import { OrganizationPills } from "./OrganizationPills"; @@ -50,9 +53,23 @@ interface IdpSyncPageViewProps { const validationSchema = Yup.object({ field: Yup.string().trim(), organization_assign_default: Yup.boolean(), - mapping: Yup.object().shape({ - [`${String}`]: Yup.array().of(Yup.string()), - }), + mapping: Yup.object() + .test( + "valid-mapping", + "Invalid organization sync settings mapping structure", + (value) => { + if (!value) return true; + return Object.entries(value).every( + ([key, arr]) => + typeof key === "string" && + Array.isArray(arr) && + arr.every((item) => { + return typeof item === "string" && isUUID(item); + }), + ); + }, + ) + .default({}), }); export const IdpOrgSyncPageView: FC = ({ @@ -78,6 +95,7 @@ export const IdpOrgSyncPageView: FC = ({ ? Object.entries(form.values.mapping).length : 0; const [isDialogOpen, setIsDialogOpen] = useState(false); + const id = useId(); const getOrgNames = (orgIds: readonly string[]) => { return orgIds.map( @@ -100,10 +118,6 @@ export const IdpOrgSyncPageView: FC = ({ form.handleSubmit(); }; - const SYNC_FIELD_ID = "sync-field"; - const ORGANIZATION_ASSIGN_DEFAULT_ID = "organization-assign-default"; - const IDP_ORGANIZATION_NAME_ID = "idp-organization-name"; - return (
{Boolean(error) && } @@ -111,15 +125,15 @@ export const IdpOrgSyncPageView: FC = ({
-