feat: add combobox for selecting claim field value for group/role idp sync (#16459)

contributes to coder/internal#330

This is a followup to coder/coder#16335

This adds a combobox that swaps with the input for the idp group and idp
role names when the sync field returns claim values from the claim field
values endpoint. If no claim field values are returned then the input
remains instead of the combobox.


<img width="806" alt="Screenshot 2025-02-05 at 20 50 48"
src="https://github.com/user-attachments/assets/bff839f5-1a2f-4cc1-8933-73fde942bc75"
/>
<img width="803" alt="Screenshot 2025-02-05 at 20 51 00"
src="https://github.com/user-attachments/assets/e9c7ca33-136a-4962-b924-770a64658dc8"
/>
This commit is contained in:
Jaayden Halko
2025-02-07 16:31:02 -05:00
committed by GitHub
parent d5595f86f8
commit 7076c4e4ab
7 changed files with 172 additions and 76 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ export const TableRow = React.forwardRef<
ref={ref}
className={cn(
"border-0 border-b border-solid border-border transition-colors",
"hover:bg-muted/50 data-[state=selected]:bg-muted",
"data-[state=selected]:bg-muted",
className,
)}
{...props}
@@ -367,7 +367,7 @@ const IdpMappingTable: FC<IdpMappingTableProps> = ({ isEmpty, children }) => {
<TableRow>
<TableCell width="45%">IdP organization</TableCell>
<TableCell width="55%">Coder organization</TableCell>
<TableCell width="10%" />
<TableCell width="5%" />
</TableRow>
</TableHeader>
<TableBody>
@@ -1,11 +1,10 @@
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import type {
Group,
GroupSyncSettings,
Organization,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import {
HelpTooltip,
HelpTooltipContent,
@@ -22,6 +21,7 @@ import {
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import { TableCell, TableRow } from "components/Table/Table";
import {
Tooltip,
TooltipContent,
@@ -30,7 +30,7 @@ import {
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
import { Plus, Trash, TriangleAlert } from "lucide-react";
import { type FC, useId, useState } from "react";
import { type FC, type KeyboardEventHandler, useId, useState } from "react";
import { docs } from "utils/docs";
import { isUUID } from "utils/uuid";
import * as Yup from "yup";
@@ -70,6 +70,7 @@ interface IdpGroupSyncFormProps {
legacyGroupMappingCount: number;
organization: Organization;
onSubmit: (data: GroupSyncSettings) => void;
onSyncFieldChange: (value: string) => void;
}
export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
@@ -81,6 +82,7 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
groupsMap,
organization,
onSubmit,
onSyncFieldChange,
}) => {
const form = useFormik<GroupSyncSettings>({
initialValues: {
@@ -97,6 +99,8 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
const [idpGroupName, setIdpGroupName] = useState("");
const [coderGroups, setCoderGroups] = useState<Option[]>([]);
const id = useId();
const [comboInputValue, setComboInputValue] = useState("");
const [open, setOpen] = useState(false);
const getGroupNames = (groupIds: readonly string[]) => {
return groupIds.map((groupId) => groupsMap.get(groupId) || groupId);
@@ -116,6 +120,21 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
form.handleSubmit();
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (
event.key === "Enter" &&
comboInputValue &&
!claimFieldValues?.some(
(value) => value === comboInputValue.toLowerCase(),
)
) {
event.preventDefault();
setIdpGroupName(comboInputValue);
setComboInputValue("");
setOpen(false);
}
};
return (
<form onSubmit={form.handleSubmit}>
<fieldset
@@ -143,6 +162,7 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
value={form.values.field}
onChange={(event) => {
void form.setFieldValue("field", event.target.value);
onSyncFieldChange(event.target.value);
}}
className="w-72"
/>
@@ -202,14 +222,31 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
<Label className="text-sm" htmlFor={`${id}-idp-group-name`}>
IdP group name
</Label>
<Input
id={`${id}-idp-group-name`}
value={idpGroupName}
className="w-72"
onChange={(event) => {
setIdpGroupName(event.target.value);
}}
/>
{claimFieldValues ? (
<Combobox
value={idpGroupName}
options={claimFieldValues}
placeholder="Select IdP group"
open={open}
onOpenChange={setOpen}
inputValue={comboInputValue}
onInputChange={setComboInputValue}
onKeyDown={handleKeyDown}
onSelect={(value) => {
setIdpGroupName(value);
setOpen(false);
}}
/>
) : (
<Input
id={`${id}-idp-group-name`}
value={idpGroupName}
className="w-72"
onChange={(event) => {
setIdpGroupName(event.target.value);
}}
/>
)}
</div>
<div className="grid items-center gap-1 flex-1">
<Label className="text-sm" htmlFor={`${id}-coder-group`}>
@@ -1,12 +1,13 @@
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 { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Link } from "components/Link/Link";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "components/Table/Table";
import type { FC } from "react";
import { docs } from "utils/docs";
@@ -22,48 +23,45 @@ export const IdpMappingTable: FC<IdpMappingTableProps> = ({
children,
}) => {
return (
<div className="flex flex-col w-full gap-2">
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="45%">IdP {type.toLocaleLowerCase()}</TableCell>
<TableCell width="55%">
Coder {type.toLocaleLowerCase()}
</TableCell>
<TableCell width="10%" />
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={rowCount === 0}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={`No ${type.toLocaleLowerCase()} mappings`}
isCompact
cta={
<Link
href={docs(
`/admin/users/idp-sync#${type.toLocaleLowerCase()}-sync`,
)}
>
How to setup IdP {type.toLocaleLowerCase()} sync
</Link>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
<div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableCell width="45%">IdP {type.toLocaleLowerCase()}</TableCell>
<TableCell width="55%">Coder {type.toLocaleLowerCase()}</TableCell>
<TableCell width="5%" />
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={rowCount === 0}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={`No ${type.toLocaleLowerCase()} mappings`}
isCompact
cta={
<Link
href={docs(
`/admin/users/idp-sync#${type.toLocaleLowerCase()}-sync`,
)}
>
How to setup IdP {type.toLocaleLowerCase()} sync
</Link>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
<div className="flex justify-end">
<div className="text-content-secondary text-xs">
Showing <strong className="text-content-primary">{rowCount}</strong>{" "}
groups
{type.toLocaleLowerCase()}
{(rowCount === 0 || rowCount > 1) && "s"}
</div>
</div>
</div>
@@ -1,7 +1,6 @@
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import type { Organization, Role, RoleSyncSettings } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import {
@@ -9,6 +8,7 @@ import {
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import { TableCell, TableRow } from "components/Table/Table";
import {
Tooltip,
TooltipContent,
@@ -17,7 +17,7 @@ import {
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
import { Plus, Trash, TriangleAlert } from "lucide-react";
import { type FC, useId, useState } from "react";
import { type FC, type KeyboardEventHandler, useId, useState } from "react";
import * as Yup from "yup";
import { ExportPolicyButton } from "./ExportPolicyButton";
import { IdpMappingTable } from "./IdpMappingTable";
@@ -53,6 +53,7 @@ interface IdpRoleSyncFormProps {
organization: Organization;
roles: Role[];
onSubmit: (data: RoleSyncSettings) => void;
onSyncFieldChange: (value: string) => void;
}
export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
@@ -62,6 +63,7 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
organization,
roles,
onSubmit,
onSyncFieldChange,
}) => {
const form = useFormik<RoleSyncSettings>({
initialValues: {
@@ -75,6 +77,8 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
const [idpRoleName, setIdpRoleName] = useState("");
const [coderRoles, setCoderRoles] = useState<Option[]>([]);
const id = useId();
const [comboInputValue, setComboInputValue] = useState("");
const [open, setOpen] = useState(false);
const handleDelete = async (idpOrg: string) => {
const newMapping = Object.fromEntries(
@@ -90,6 +94,21 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
form.handleSubmit();
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (
event.key === "Enter" &&
comboInputValue &&
!claimFieldValues?.some(
(value) => value === comboInputValue.toLowerCase(),
)
) {
event.preventDefault();
setIdpRoleName(comboInputValue);
setComboInputValue("");
setOpen(false);
}
};
return (
<form onSubmit={form.handleSubmit}>
<fieldset
@@ -114,6 +133,7 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
value={form.values.field}
onChange={(event) => {
void form.setFieldValue("field", event.target.value);
onSyncFieldChange(event.target.value);
}}
className="w-72"
/>
@@ -143,14 +163,31 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
<Label className="text-sm" htmlFor={`${id}-idp-role-name`}>
IdP role name
</Label>
<Input
id={`${id}-idp-role-name`}
value={idpRoleName}
className="w-72"
onChange={(event) => {
setIdpRoleName(event.target.value);
}}
/>
{claimFieldValues ? (
<Combobox
value={idpRoleName}
options={claimFieldValues}
placeholder="Select IdP role"
open={open}
onOpenChange={setOpen}
inputValue={comboInputValue}
onInputChange={setComboInputValue}
onKeyDown={handleKeyDown}
onSelect={(value) => {
setIdpRoleName(value);
setOpen(false);
}}
/>
) : (
<Input
id={`${id}-idp-role-name`}
value={idpRoleName}
className="w-72"
onChange={(event) => {
setIdpRoleName(event.target.value);
}}
/>
)}
</div>
<div className="grid items-center gap-1 flex-1">
<Label className="text-sm" htmlFor={`${id}-coder-role`}>
@@ -8,6 +8,7 @@ import {
roleIdpSyncSettings,
} from "api/queries/organizations";
import { organizationRoles } from "api/queries/roles";
import type { GroupSyncSettings, RoleSyncSettings } from "api/typesGenerated";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { displayError } from "components/GlobalSnackbar/utils";
@@ -16,7 +17,7 @@ import { Link } from "components/Link/Link";
import { Paywall } from "components/Paywall/Paywall";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
import type { FC } from "react";
import { type FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
import { useParams, useSearchParams } from "react-router-dom";
@@ -26,13 +27,15 @@ import IdpSyncPageView from "./IdpSyncPageView";
export const IdpSyncPage: FC = () => {
const queryClient = useQueryClient();
// IdP sync does not have its own entitlement and is based on templace_rbac
const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility();
const { organization: organizationName } = useParams() as {
organization: string;
};
// IdP sync does not have its own entitlement and is based on templace_rbac
const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility();
const { organizations } = useOrganizationSettings();
const organization = organizations?.find((o) => o.name === organizationName);
const [groupField, setGroupField] = useState("");
const [roleField, setRoleField] = useState("");
const [
groupIdpSyncSettingsQuery,
@@ -48,12 +51,25 @@ export 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"
? groupIdpSyncSettingsQuery.data?.field
: roleIdpSyncSettingsQuery.data?.field;
const field = tab === "groups" ? groupField : roleField;
const fieldValuesQuery = useQuery(
field
@@ -121,6 +137,8 @@ export const IdpSyncPage: FC = () => {
groupsMap={groupsMap}
roles={rolesQuery.data}
organization={organization}
onGroupSyncFieldChange={setGroupField}
onRoleSyncFieldChange={setRoleField}
error={error}
onSubmitGroupSyncSettings={async (data) => {
try {
@@ -21,6 +21,8 @@ interface IdpSyncPageViewProps {
groupsMap: Map<string, string>;
roles: Role[] | undefined;
organization: Organization;
onGroupSyncFieldChange: (value: string) => void;
onRoleSyncFieldChange: (value: string) => void;
error?: unknown;
onSubmitGroupSyncSettings: (data: GroupSyncSettings) => void;
onSubmitRoleSyncSettings: (data: RoleSyncSettings) => void;
@@ -35,6 +37,8 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
groupsMap,
roles,
organization,
onGroupSyncFieldChange,
onRoleSyncFieldChange,
error,
onSubmitGroupSyncSettings,
onSubmitRoleSyncSettings,
@@ -76,6 +80,7 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
groupsMap={groupsMap}
organization={organization}
onSubmit={onSubmitGroupSyncSettings}
onSyncFieldChange={onGroupSyncFieldChange}
/>
) : (
<IdpRoleSyncForm
@@ -85,6 +90,7 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
roles={roles || []}
organization={organization}
onSubmit={onSubmitRoleSyncSettings}
onSyncFieldChange={onRoleSyncFieldChange}
/>
)}
</div>