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