mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
feat: show warning on unrecognized idp org mapping claims (#16478)
This commit is contained in:
+13
-7
@@ -698,7 +698,7 @@ class ApiMethods {
|
||||
}
|
||||
|
||||
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
|
||||
`/api/v2/organizations/${organization}/provisionerdaemons?${params.toString()}`,
|
||||
`/api/v2/organizations/${organization}/provisionerdaemons?${params}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -787,19 +787,25 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getIdpSyncClaimFieldValues = async (claimField: string) => {
|
||||
const response = await this.axios.get<string[]>(
|
||||
`/api/v2/settings/idpsync/field-values?claimField=${claimField}`,
|
||||
getDeploymentIdpSyncFieldValues = async (
|
||||
field: string,
|
||||
): Promise<readonly string[]> => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("claimField", field);
|
||||
const response = await this.axios.get<readonly string[]>(
|
||||
`/api/v2/settings/idpsync/field-values?${params}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getIdpSyncClaimFieldValuesByOrganization = async (
|
||||
getOrganizationIdpSyncClaimFieldValues = async (
|
||||
organization: string,
|
||||
claimField: string,
|
||||
field: string,
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("claimField", field);
|
||||
const response = await this.axios.get<TypesGen.Response>(
|
||||
`/api/v2/organizations/${organization}/settings/idpsync/field-values?claimField=${claimField}`,
|
||||
`/api/v2/organizations/${organization}/settings/idpsync/field-values?${params}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -29,3 +29,10 @@ export const deploymentSSHConfig = () => {
|
||||
queryFn: API.getDeploymentSSHConfig,
|
||||
};
|
||||
};
|
||||
|
||||
export const deploymentIdpSyncFieldValues = (field: string) => {
|
||||
return {
|
||||
queryKey: ["deployment", "idpSync", "fieldValues", field],
|
||||
queryFn: () => API.getDeploymentIdpSyncFieldValues(field),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -341,32 +341,16 @@ export const organizationsPermissions = (
|
||||
|
||||
export const getOrganizationIdpSyncClaimFieldValuesKey = (
|
||||
organization: string,
|
||||
claimField: string,
|
||||
) => [organization, claimField, "organizationIdpSyncClaimFieldValues"];
|
||||
field: string,
|
||||
) => [organization, "idpSync", "fieldValues", field];
|
||||
|
||||
export const organizationIdpSyncClaimFieldValues = (
|
||||
organization: string,
|
||||
claimField: string,
|
||||
field: string,
|
||||
) => {
|
||||
return {
|
||||
queryKey: getOrganizationIdpSyncClaimFieldValuesKey(
|
||||
organization,
|
||||
claimField,
|
||||
),
|
||||
queryKey: getOrganizationIdpSyncClaimFieldValuesKey(organization, field),
|
||||
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,
|
||||
API.getOrganizationIdpSyncClaimFieldValues(organization, field),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cn } from "utils/cn";
|
||||
|
||||
interface ComboboxProps {
|
||||
value: string;
|
||||
options?: string[];
|
||||
options?: readonly string[];
|
||||
placeholder?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
||||
@@ -12,16 +12,12 @@ const meta: Meta<typeof TooltipProvider> = {
|
||||
component: TooltipProvider,
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Hover</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add to library</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Hover</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add to library</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { deploymentIdpSyncFieldValues } from "api/queries/deployment";
|
||||
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";
|
||||
@@ -21,26 +21,23 @@ 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();
|
||||
const { organizations } = useDashboard();
|
||||
const {
|
||||
data: orgSyncSettingsData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
...organizationIdpSyncSettings(isIdpSyncEnabled),
|
||||
onSuccess: (data) => {
|
||||
if (data?.field) {
|
||||
setClaimField(data.field);
|
||||
}
|
||||
},
|
||||
});
|
||||
const settingsQuery = useQuery(organizationIdpSyncSettings(isIdpSyncEnabled));
|
||||
|
||||
const { data: claimFieldValues } = useQuery(
|
||||
idpSyncClaimFieldValues(claimField),
|
||||
const [field, setField] = useState("");
|
||||
useEffect(() => {
|
||||
if (!settingsQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setField(settingsQuery.data.field);
|
||||
}, [settingsQuery.data]);
|
||||
|
||||
const fieldValuesQuery = useQuery(
|
||||
field ? deploymentIdpSyncFieldValues(field) : { enabled: false },
|
||||
);
|
||||
|
||||
const patchOrganizationSyncSettingsMutation = useMutation(
|
||||
@@ -58,14 +55,10 @@ export const IdpOrgSyncPage: FC = () => {
|
||||
}
|
||||
}, [patchOrganizationSyncSettingsMutation.error]);
|
||||
|
||||
if (isLoading) {
|
||||
if (settingsQuery.isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const handleSyncFieldChange = (value: string) => {
|
||||
setClaimField(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -84,7 +77,7 @@ export const IdpOrgSyncPage: FC = () => {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<ExportPolicyButton syncSettings={orgSyncSettingsData} />
|
||||
<ExportPolicyButton syncSettings={settingsQuery.data} />
|
||||
</header>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isIdpSyncEnabled}>
|
||||
@@ -96,8 +89,10 @@ export const IdpOrgSyncPage: FC = () => {
|
||||
</Cond>
|
||||
<Cond>
|
||||
<IdpOrgSyncPageView
|
||||
organizationSyncSettings={orgSyncSettingsData}
|
||||
organizationSyncSettings={settingsQuery.data}
|
||||
claimFieldValues={fieldValuesQuery.data}
|
||||
organizations={organizations}
|
||||
onSyncFieldChange={setField}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await patchOrganizationSyncSettingsMutation.mutateAsync(data);
|
||||
@@ -111,9 +106,7 @@ export const IdpOrgSyncPage: FC = () => {
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSyncFieldChange={handleSyncFieldChange}
|
||||
claimFieldValues={claimFieldValues}
|
||||
error={error || patchOrganizationSyncSettingsMutation.error}
|
||||
error={settingsQuery.error || fieldValuesQuery.error}
|
||||
/>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
|
||||
+17
-16
@@ -5,12 +5,19 @@ import {
|
||||
MockOrganization2,
|
||||
MockOrganizationSyncSettings,
|
||||
MockOrganizationSyncSettings2,
|
||||
MockOrganizationSyncSettingsEmpty,
|
||||
} from "testHelpers/entities";
|
||||
import { IdpOrgSyncPageView } from "./IdpOrgSyncPageView";
|
||||
|
||||
const meta: Meta<typeof IdpOrgSyncPageView> = {
|
||||
title: "pages/IdpOrgSyncPageView",
|
||||
component: IdpOrgSyncPageView,
|
||||
args: {
|
||||
organizationSyncSettings: MockOrganizationSyncSettings2,
|
||||
claimFieldValues: Object.keys(MockOrganizationSyncSettings2.mapping),
|
||||
organizations: [MockOrganization, MockOrganization2],
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -18,35 +25,29 @@ type Story = StoryObj<typeof IdpOrgSyncPageView>;
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
organizationSyncSettings: {
|
||||
field: "",
|
||||
mapping: {},
|
||||
organization_assign_default: true,
|
||||
},
|
||||
organizations: [MockOrganization, MockOrganization2],
|
||||
error: undefined,
|
||||
organizationSyncSettings: MockOrganizationSyncSettingsEmpty,
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
organizationSyncSettings: MockOrganizationSyncSettings2,
|
||||
organizations: [MockOrganization, MockOrganization2],
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
export const Default: Story = {};
|
||||
|
||||
export const HasError: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
error: "This is a test error",
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingGroups: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
organizationSyncSettings: MockOrganizationSyncSettings,
|
||||
claimFieldValues: Object.keys(MockOrganizationSyncSettings.mapping),
|
||||
organizations: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingClaim: Story = {
|
||||
args: {
|
||||
claimFieldValues: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import type {
|
||||
Organization,
|
||||
OrganizationSyncSettings,
|
||||
@@ -28,12 +29,8 @@ import {
|
||||
MultiSelectCombobox,
|
||||
type Option,
|
||||
} from "components/MultiSelectCombobox/MultiSelectCombobox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Switch } from "components/Switch/Switch";
|
||||
import {
|
||||
Table,
|
||||
@@ -42,10 +39,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useFormik } from "formik";
|
||||
import { Check, ChevronDown, CornerDownLeft, Plus, Trash } from "lucide-react";
|
||||
import { Plus, Trash, TriangleAlert } 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";
|
||||
@@ -53,10 +54,10 @@ import { OrganizationPills } from "./OrganizationPills";
|
||||
|
||||
interface IdpSyncPageViewProps {
|
||||
organizationSyncSettings: OrganizationSyncSettings | undefined;
|
||||
claimFieldValues: readonly string[] | undefined;
|
||||
organizations: readonly Organization[];
|
||||
onSubmit: (data: OrganizationSyncSettings) => void;
|
||||
onSyncFieldChange: (value: string) => void;
|
||||
claimFieldValues: string[] | undefined;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
@@ -84,10 +85,10 @@ const validationSchema = Yup.object({
|
||||
|
||||
export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
|
||||
organizationSyncSettings,
|
||||
claimFieldValues,
|
||||
organizations,
|
||||
onSubmit,
|
||||
onSyncFieldChange,
|
||||
claimFieldValues,
|
||||
error,
|
||||
}) => {
|
||||
const form = useFormik<OrganizationSyncSettings>({
|
||||
@@ -313,6 +314,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
|
||||
idpOrg={idpOrg}
|
||||
coderOrgs={getOrgNames(organizations)}
|
||||
onDelete={handleDelete}
|
||||
exists={claimFieldValues?.includes(idpOrg)}
|
||||
/>
|
||||
))}
|
||||
</IdpMappingTable>
|
||||
@@ -398,18 +400,43 @@ const IdpMappingTable: FC<IdpMappingTableProps> = ({ isEmpty, children }) => {
|
||||
|
||||
interface OrganizationRowProps {
|
||||
idpOrg: string;
|
||||
exists: boolean | undefined;
|
||||
coderOrgs: readonly string[];
|
||||
onDelete: (idpOrg: string) => void;
|
||||
}
|
||||
|
||||
const OrganizationRow: FC<OrganizationRowProps> = ({
|
||||
idpOrg,
|
||||
exists = true,
|
||||
coderOrgs,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<TableRow data-testid={`idp-org-${idpOrg}`}>
|
||||
<TableCell>{idpOrg}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-2 text-content-primary">
|
||||
{idpOrg}
|
||||
{!exists && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlert className="size-icon-xs cursor-pointer text-content-warning" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
align="start"
|
||||
alignOffset={-8}
|
||||
sideOffset={8}
|
||||
className="p-2 text-xs text-content-secondary max-w-sm"
|
||||
>
|
||||
This value has not be seen in the specified claim field
|
||||
before. You might want to check your IdP configuration and
|
||||
ensure that this value is not misspelled.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OrganizationPills organizations={coderOrgs} />
|
||||
</TableCell>
|
||||
|
||||
@@ -2720,6 +2720,13 @@ export const MockOrganizationSyncSettings2: TypesGen.OrganizationSyncSettings =
|
||||
organization_assign_default: true,
|
||||
};
|
||||
|
||||
export const MockOrganizationSyncSettingsEmpty: TypesGen.OrganizationSyncSettings =
|
||||
{
|
||||
field: "",
|
||||
mapping: {},
|
||||
organization_assign_default: true,
|
||||
};
|
||||
|
||||
export const MockGroup: TypesGen.Group = {
|
||||
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
|
||||
name: "Front-End",
|
||||
|
||||
@@ -33,6 +33,7 @@ module.exports = {
|
||||
success: "hsl(var(--content-success))",
|
||||
danger: "hsl(var(--content-danger))",
|
||||
link: "hsl(var(--content-link))",
|
||||
warning: "hsl(var(--content-warning))",
|
||||
},
|
||||
surface: {
|
||||
primary: "hsl(var(--surface-primary))",
|
||||
|
||||
Reference in New Issue
Block a user