diff --git a/site/e2e/tests/deployment/idpOrgSync.spec.ts b/site/e2e/tests/deployment/idpOrgSync.spec.ts index a5162d4055658..d77ddb1593fd3 100644 --- a/site/e2e/tests/deployment/idpOrgSync.spec.ts +++ b/site/e2e/tests/deployment/idpOrgSync.spec.ts @@ -150,6 +150,11 @@ test.describe("IdpOrgSyncPage", () => { waitUntil: "domcontentloaded", }); + const syncField = page.getByRole("textbox", { + name: "Organization sync field", + }); + await syncField.fill(""); + const idpOrgInput = page.getByLabel("IdP organization name"); const addButton = page.getByRole("button", { name: /Add IdP organization/i, @@ -157,7 +162,8 @@ test.describe("IdpOrgSyncPage", () => { await expect(addButton).toBeDisabled(); - await idpOrgInput.fill("new-idp-org"); + const idpOrgName = randomName(); + await idpOrgInput.fill(idpOrgName); // Select Coder organization from combobox const orgSelector = page.getByPlaceholder("Select organization"); @@ -177,11 +183,9 @@ test.describe("IdpOrgSyncPage", () => { await addButton.click(); // Verify new mapping appears in table - const newRow = page.getByTestId("idp-org-new-idp-org"); + const newRow = page.getByTestId(`idp-org-${idpOrgName}`); await expect(newRow).toBeVisible(); - await expect( - newRow.getByRole("cell", { name: "new-idp-org" }), - ).toBeVisible(); + await expect(newRow.getByRole("cell", { name: idpOrgName })).toBeVisible(); await expect(newRow.getByRole("cell", { name: orgName })).toBeVisible(); await expect( diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 26491efb10565..cd21b5b063ac6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -787,6 +787,23 @@ class ApiMethods { return response.data; }; + getIdpSyncClaimFieldValues = async (claimField: string) => { + const response = await this.axios.get( + `/api/v2/settings/idpsync/field-values?claimField=${claimField}`, + ); + return response.data; + }; + + getIdpSyncClaimFieldValuesByOrganization = async ( + organization: string, + claimField: string, + ) => { + const response = await this.axios.get( + `/api/v2/organizations/${organization}/settings/idpsync/field-values?claimField=${claimField}`, + ); + return response.data; + }; + getTemplate = async (templateId: string): Promise => { const response = await this.axios.get( `/api/v2/templates/${templateId}`, diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 0cc8168243c16..33ef19f0d2654 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -338,3 +338,35 @@ export const organizationsPermissions = ( }, }; }; + +export const getOrganizationIdpSyncClaimFieldValuesKey = ( + organization: string, + claimField: string, +) => [organization, claimField, "organizationIdpSyncClaimFieldValues"]; + +export const organizationIdpSyncClaimFieldValues = ( + organization: string, + claimField: string, +) => { + return { + queryKey: getOrganizationIdpSyncClaimFieldValuesKey( + organization, + claimField, + ), + queryFn: () => + API.getIdpSyncClaimFieldValuesByOrganization(organization, claimField), + }; +}; + +export const getIdpSyncClaimFieldValuesKey = (claimField: string) => [ + claimField, + "idpSyncClaimFieldValues", +]; + +export const idpSyncClaimFieldValues = (claimField: string) => { + return { + queryKey: getIdpSyncClaimFieldValuesKey(claimField), + queryFn: () => API.getIdpSyncClaimFieldValues(claimField), + enabled: !!claimField, + }; +}; diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx new file mode 100644 index 0000000000000..f5447b3a87062 --- /dev/null +++ b/site/src/components/Combobox/Combobox.tsx @@ -0,0 +1,93 @@ +import { Button } from "components/Button/Button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "components/Command/Command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; +import { Check, ChevronDown, CornerDownLeft } from "lucide-react"; +import type { FC, KeyboardEventHandler } from "react"; +import { cn } from "utils/cn"; + +interface ComboboxProps { + value: string; + options?: string[]; + placeholder?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + inputValue: string; + onInputChange: (value: string) => void; + onKeyDown?: KeyboardEventHandler; + onSelect: (value: string) => void; +} + +export const Combobox: FC = ({ + value, + options = [], + placeholder = "Select option", + open, + onOpenChange, + inputValue, + onInputChange, + onKeyDown, + onSelect, +}) => { + return ( + + + + + + + + + +

No results found

+ + Enter custom value + + +
+ + {options.map((option) => ( + { + onSelect(currentValue === value ? "" : currentValue); + }} + > + {option} + {value === option && ( + + )} + + ))} + +
+
+
+
+ ); +}; diff --git a/site/src/components/Command/Command.tsx b/site/src/components/Command/Command.tsx index bbdc5684cb19d..018f3da237e48 100644 --- a/site/src/components/Command/Command.tsx +++ b/site/src/components/Command/Command.tsx @@ -53,7 +53,7 @@ export const CommandInput = forwardRef< (({ className, ...props }, ref) => ( )); diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 702be6a64d582..83f2aeed41cd4 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -572,7 +572,7 @@ export const MultiSelectCombobox = forwardRef< > - + diff --git a/site/src/components/Select/Select.tsx b/site/src/components/Select/Select.tsx index a0da638c907a2..ececcc2fc9950 100644 --- a/site/src/components/Select/Select.tsx +++ b/site/src/components/Select/Select.tsx @@ -20,17 +20,18 @@ export const SelectTrigger = React.forwardRef< span]:line-clamp-1", + `flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md + border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm + ring-offset-background text-content-secondary placeholder:text-content-secondary focus:outline-none, + focus:ring-2 focus:ring-content-link disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link`, className, )} {...props} > {children} - + )); @@ -65,7 +66,7 @@ export const SelectScrollDownButton = React.forwardRef< )} {...props} > - + )); SelectScrollDownButton.displayName = diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index ef805861d1543..8d913edf87df3 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -18,7 +18,7 @@ import { SettingsSidebarNavItem, } from "components/Sidebar/Sidebar"; import type { Permissions } from "contexts/auth/permissions"; -import { ChevronDown, Plus } from "lucide-react"; +import { Check, ChevronDown, Plus } from "lucide-react"; import { useDashboard } from "modules/dashboard/useDashboard"; import { type FC, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -147,6 +147,13 @@ const OrganizationsSettingsNavigation: FC< {organization?.display_name || organization?.name} + {activeOrganization.name === organization.name && ( + + )} ))} diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index d08b3aac4ab1a..4d5b53e0f3ea2 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -3,6 +3,7 @@ import { organizationIdpSyncSettings, patchOrganizationSyncSettings, } from "api/queries/idpsync"; +import { idpSyncClaimFieldValues } from "api/queries/organizations"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { displayError } from "components/GlobalSnackbar/utils"; import { displaySuccess } from "components/GlobalSnackbar/utils"; @@ -11,7 +12,7 @@ import { Loader } from "components/Loader/Loader"; import { Paywall } from "components/Paywall/Paywall"; import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; -import { type FC, useEffect } from "react"; +import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { docs } from "utils/docs"; @@ -20,6 +21,7 @@ import { ExportPolicyButton } from "./ExportPolicyButton"; import IdpOrgSyncPageView from "./IdpOrgSyncPageView"; export const IdpOrgSyncPage: FC = () => { + const [claimField, setClaimField] = useState(""); const queryClient = useQueryClient(); // IdP sync does not have its own entitlement and is based on templace_rbac const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility(); @@ -28,7 +30,18 @@ export const IdpOrgSyncPage: FC = () => { data: orgSyncSettingsData, isLoading, error, - } = useQuery(organizationIdpSyncSettings(isIdpSyncEnabled)); + } = useQuery({ + ...organizationIdpSyncSettings(isIdpSyncEnabled), + onSuccess: (data) => { + if (data?.field) { + setClaimField(data.field); + } + }, + }); + + const { data: claimFieldValues } = useQuery( + idpSyncClaimFieldValues(claimField), + ); const patchOrganizationSyncSettingsMutation = useMutation( patchOrganizationSyncSettings(queryClient), @@ -49,6 +62,10 @@ export const IdpOrgSyncPage: FC = () => { return ; } + const handleSyncFieldChange = (value: string) => { + setClaimField(value); + }; + return ( <> @@ -94,6 +111,8 @@ export const IdpOrgSyncPage: FC = () => { ); } }} + onSyncFieldChange={handleSyncFieldChange} + claimFieldValues={claimFieldValues} error={error || patchOrganizationSyncSettingsMutation.error} /> diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx index 7ed1b85e8c9dd..031234da0da25 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx @@ -1,15 +1,10 @@ -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableRow from "@mui/material/TableRow"; import type { Organization, OrganizationSyncSettings, } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; +import { Combobox } from "components/Combobox/Combobox"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { Dialog, @@ -33,11 +28,24 @@ import { MultiSelectCombobox, type Option, } from "components/MultiSelectCombobox/MultiSelectCombobox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; import { Spinner } from "components/Spinner/Spinner"; import { Switch } from "components/Switch/Switch"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableRow, +} from "components/Table/Table"; import { useFormik } from "formik"; -import { Plus, Trash } from "lucide-react"; -import { type FC, useId, useState } from "react"; +import { Check, ChevronDown, CornerDownLeft, Plus, Trash } from "lucide-react"; +import { type FC, type KeyboardEventHandler, useId, useState } from "react"; +import { cn } from "utils/cn"; import { docs } from "utils/docs"; import { isUUID } from "utils/uuid"; import * as Yup from "yup"; @@ -47,6 +55,8 @@ interface IdpSyncPageViewProps { organizationSyncSettings: OrganizationSyncSettings | undefined; organizations: readonly Organization[]; onSubmit: (data: OrganizationSyncSettings) => void; + onSyncFieldChange: (value: string) => void; + claimFieldValues: string[] | undefined; error?: unknown; } @@ -76,6 +86,8 @@ export const IdpOrgSyncPageView: FC = ({ organizationSyncSettings, organizations, onSubmit, + onSyncFieldChange, + claimFieldValues, error, }) => { const form = useFormik({ @@ -91,11 +103,13 @@ export const IdpOrgSyncPageView: FC = ({ }); const [coderOrgs, setCoderOrgs] = useState([]); const [idpOrgName, setIdpOrgName] = useState(""); + const [inputValue, setInputValue] = useState(""); const organizationMappingCount = form.values.mapping ? Object.entries(form.values.mapping).length : 0; const [isDialogOpen, setIsDialogOpen] = useState(false); const id = useId(); + const [open, setOpen] = useState(false); const getOrgNames = (orgIds: readonly string[]) => { return orgIds.map( @@ -118,6 +132,19 @@ export const IdpOrgSyncPageView: FC = ({ form.handleSubmit(); }; + const handleKeyDown: KeyboardEventHandler = (event) => { + if ( + event.key === "Enter" && + inputValue && + !claimFieldValues?.some((value) => value === inputValue.toLowerCase()) + ) { + event.preventDefault(); + setIdpOrgName(inputValue); + setInputValue(""); + setOpen(false); + } + }; + return (
{Boolean(error) && } @@ -135,6 +162,7 @@ export const IdpOrgSyncPageView: FC = ({ value={form.values.field} onChange={(event) => { void form.setFieldValue("field", event.target.value); + onSyncFieldChange(event.target.value); }} />