From f5186699ad74efe1c1eea593ee364349d30a44c1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 27 Jan 2025 18:31:48 +0000 Subject: [PATCH] feat: enable editing of IDP sync configuration for groups and roles in the UI (#16098) contributes to #15290 The goal of this PR is to port the work to implement CRUD in the UI for IDP organization sync settings and apply this to group and role IDP sync settings. Screenshot 2025-01-16 at 20 25 21 Screenshot 2025-01-16 at 20 25 39 --- site/e2e/api.ts | 38 +- site/e2e/tests/deployment/idpOrgSync.spec.ts | 44 +- .../tests/organizations/idpGroupSync.spec.ts | 184 ++++++++ .../tests/organizations/idpRoleSync.spec.ts | 167 +++++++ site/src/api/api.ts | 30 ++ site/src/api/queries/organizations.ts | 28 ++ site/src/components/Button/Button.tsx | 5 +- site/src/components/Input/Input.tsx | 2 +- site/src/components/Link/Link.tsx | 8 +- .../MultiSelectCombobox.tsx | 8 +- site/src/components/Tabs/Tabs.tsx | 68 +-- .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 10 +- .../IdpOrgSyncPage/IdpOrgSyncPageView.tsx | 97 ++-- .../CustomRolesPage/CustomRolesPageView.tsx | 1 - .../ExportPolicyButton.stories.tsx | 4 +- .../IdpSyncPage/ExportPolicyButton.tsx | 4 +- .../IdpSyncPage/IdpGroupSyncForm.tsx | 371 ++++++++++++++++ .../IdpSyncPage/IdpMappingTable.tsx | 71 +++ .../IdpSyncPage/IdpRoleSyncForm.tsx | 250 +++++++++++ .../IdpSyncPage/IdpSyncHelpTooltip.tsx | 31 -- .../IdpSyncPage/IdpSyncPage.tsx | 141 +++--- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 4 +- .../IdpSyncPage/IdpSyncPageView.tsx | 418 ++---------------- .../src/pages/TemplatePage/TemplateLayout.tsx | 7 +- 24 files changed, 1404 insertions(+), 587 deletions(-) create mode 100644 site/e2e/tests/organizations/idpGroupSync.spec.ts create mode 100644 site/e2e/tests/organizations/idpRoleSync.spec.ts create mode 100644 site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx create mode 100644 site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpMappingTable.tsx create mode 100644 site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx delete mode 100644 site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncHelpTooltip.tsx diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 96a2e37260..902485b7b1 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 231d15dab1..c8e6b5a33b 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 0000000000..2ea9d02388 --- /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 0000000000..3374151a85 --- /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 ac4ef4a1ca..26491efb10 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 c3f5a4ebd3..0cc8168243 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 bc25b0c077..93e1a479aa 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 f1abd6ecc9..b50d6415a8 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 ebeaa76267..bdb5aa063d 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 13f405affc..d08b3aac4a 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 ae90e26c3a..7ed1b85e8c 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 = ({
-