feat: orgs IDP sync - add combobox to select claim field value when sync field is set (#16335)

contributes to coder/internal#330

For organizations IdP sync:

1. when the sync field is set, call the claim field values API to see if
the sync field is a valid claim field and return an array of claim field
values
2. If there are 1 or more claim field values, replace the input
component for entering the IdP organization name with a combobox
populated with the claim field values
3. The user can now select a value from the dropdown or enter a custom
value

Tests will be added in a separate PR

The same functionality for Group and Role sync will be handled in a
separate PR.


<img width="832" alt="Screenshot 2025-02-04 at 17 45 42"
src="https://github.com/user-attachments/assets/d9123260-f6c6-4914-869b-f11b14773ea1"
/>

<img width="786" alt="Screenshot 2025-02-04 at 17 45 58"
src="https://github.com/user-attachments/assets/06138320-d50c-43bd-b2b9-676ffee42e1a"
/>

<img width="810" alt="Screenshot 2025-02-04 at 17 46 14"
src="https://github.com/user-attachments/assets/50b74909-4629-435d-9774-67d281bbc442"
/>

<img width="825" alt="Screenshot 2025-02-04 at 17 52 08"
src="https://github.com/user-attachments/assets/7470281e-e88f-497b-a613-52bf8007dae8"
/>
This commit is contained in:
Jaayden Halko
2025-02-05 08:40:07 -05:00
committed by GitHub
parent 42451aa615
commit 7f44189ed2
10 changed files with 288 additions and 68 deletions
+9 -5
View File
@@ -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(
+17
View File
@@ -787,6 +787,23 @@ class ApiMethods {
return response.data;
};
getIdpSyncClaimFieldValues = async (claimField: string) => {
const response = await this.axios.get<string[]>(
`/api/v2/settings/idpsync/field-values?claimField=${claimField}`,
);
return response.data;
};
getIdpSyncClaimFieldValuesByOrganization = async (
organization: string,
claimField: string,
) => {
const response = await this.axios.get<TypesGen.Response>(
`/api/v2/organizations/${organization}/settings/idpsync/field-values?claimField=${claimField}`,
);
return response.data;
};
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
const response = await this.axios.get<TypesGen.Template>(
`/api/v2/templates/${templateId}`,
+32
View File
@@ -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,
};
};
+93
View File
@@ -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<HTMLInputElement>;
onSelect: (value: string) => void;
}
export const Combobox: FC<ComboboxProps> = ({
value,
options = [],
placeholder = "Select option",
open,
onOpenChange,
inputValue,
onInputChange,
onKeyDown,
onSelect,
}) => {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-72 justify-between group"
>
<span className={cn(!value && "text-content-secondary")}>
{value || placeholder}
</span>
<ChevronDown className="size-icon-sm text-content-secondary group-hover:text-content-primary" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<Command>
<CommandInput
placeholder="Search or enter custom value"
value={inputValue}
onValueChange={onInputChange}
onKeyDown={onKeyDown}
/>
<CommandList>
<CommandEmpty>
<p>No results found</p>
<span className="flex flex-row items-center justify-center gap-1">
Enter custom value
<CornerDownLeft className="size-icon-sm bg-surface-tertiary rounded-sm p-1" />
</span>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={(currentValue) => {
onSelect(currentValue === value ? "" : currentValue);
}}
>
{option}
{value === option && (
<Check className="size-icon-sm ml-auto" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
+5 -2
View File
@@ -53,7 +53,7 @@ export const CommandInput = forwardRef<
<CommandPrimitive.Input
ref={ref}
className={cn(
`flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none
`flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none
placeholder:text-content-secondary
disabled:cursor-not-allowed disabled:opacity-50`,
className,
@@ -69,7 +69,10 @@ export const CommandList = forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-96 overflow-y-auto overflow-x-hidden", className)}
className={cn(
"max-h-96 overflow-y-auto overflow-x-hidden border-0 border-t border-solid border-border",
className,
)}
{...props}
/>
));
@@ -572,7 +572,7 @@ export const MultiSelectCombobox = forwardRef<
>
<X className="h-5 w-5" />
</button>
<ChevronDown className="h-5 w-5 cursor-pointer text-content-secondary hover:text-content-primary" />
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</div>
</div>
</div>
+7 -6
View File
@@ -20,17 +20,18 @@ export const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"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-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>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}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-icon-sm opacity-50" />
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
@@ -65,7 +66,7 @@ export const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
<ChevronDown className="size-icon-sm" />
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
@@ -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<
<span className="truncate">
{organization?.display_name || organization?.name}
</span>
{activeOrganization.name === organization.name && (
<Check
size={16}
strokeWidth={2}
className="ml-auto"
/>
)}
</CommandItem>
))}
</div>
@@ -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 <Loader />;
}
const handleSyncFieldChange = (value: string) => {
setClaimField(value);
};
return (
<>
<Helmet>
@@ -94,6 +111,8 @@ export const IdpOrgSyncPage: FC = () => {
);
}
}}
onSyncFieldChange={handleSyncFieldChange}
claimFieldValues={claimFieldValues}
error={error || patchOrganizationSyncSettingsMutation.error}
/>
</Cond>
@@ -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<IdpSyncPageViewProps> = ({
organizationSyncSettings,
organizations,
onSubmit,
onSyncFieldChange,
claimFieldValues,
error,
}) => {
const form = useFormik<OrganizationSyncSettings>({
@@ -91,11 +103,13 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
});
const [coderOrgs, setCoderOrgs] = useState<Option[]>([]);
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<IdpSyncPageViewProps> = ({
form.handleSubmit();
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (
event.key === "Enter" &&
inputValue &&
!claimFieldValues?.some((value) => value === inputValue.toLowerCase())
) {
event.preventDefault();
setIdpOrgName(inputValue);
setInputValue("");
setOpen(false);
}
};
return (
<div className="flex flex-col gap-2">
{Boolean(error) && <ErrorAlert error={error} />}
@@ -135,6 +162,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
value={form.values.field}
onChange={(event) => {
void form.setFieldValue("field", event.target.value);
onSyncFieldChange(event.target.value);
}}
/>
<Button
@@ -184,20 +212,38 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
{form.errors.field}
</p>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-7">
<div className="flex flex-row pt-8 gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={`${id}-idp-org-name`}>
IdP organization name
</Label>
<Input
id={`${id}-idp-org-name`}
value={idpOrgName}
className="min-w-72 w-72"
onChange={(event) => {
setIdpOrgName(event.target.value);
}}
/>
{claimFieldValues ? (
<Combobox
value={idpOrgName}
options={claimFieldValues}
placeholder="Select IdP organization"
open={open}
onOpenChange={setOpen}
inputValue={inputValue}
onInputChange={setInputValue}
onKeyDown={handleKeyDown}
onSelect={(value: string) => {
setIdpOrgName(value);
setOpen(false);
}}
/>
) : (
<Input
id={`${id}-idp-org-name`}
value={idpOrgName}
className="w-72"
onChange={(event) => {
setIdpOrgName(event.target.value);
}}
/>
)}
</div>
<div className="grid items-center gap-1 flex-1">
<Label className="text-sm" htmlFor={`${id}-coder-org`}>
@@ -218,7 +264,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
placeholder="Select organization"
emptyIndicator={
<p className="text-center text-md text-content-primary">
All organizations selected
No organizations found
</p>
}
/>
@@ -315,40 +361,38 @@ interface IdpMappingTableProps {
const IdpMappingTable: FC<IdpMappingTableProps> = ({ isEmpty, children }) => {
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="45%">IdP organization</TableCell>
<TableCell width="55%">Coder organization</TableCell>
<TableCell width="10%" />
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={"No organization mappings"}
isCompact
cta={
<Link
href={docs("/admin/users/idp-sync#organization-sync")}
>
How to set up IdP organization sync
</Link>
}
/>
</TableCell>
</TableRow>
</Cond>
<Table>
<TableHeader>
<TableRow>
<TableCell width="45%">IdP organization</TableCell>
<TableCell width="55%">Coder organization</TableCell>
<TableCell width="10%" />
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={"No organization mappings"}
isCompact
cta={
<Link
href={docs("/admin/users/idp-sync#organization-sync")}
>
How to set up IdP organization sync
</Link>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
);
};