mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user