feat: show warning on unrecognized idp org mapping claims (#16478)

This commit is contained in:
ケイラ
2025-02-06 16:45:03 -07:00
committed by GitHub
parent 33a89abf7a
commit bcfeb726d6
10 changed files with 113 additions and 91 deletions
+13 -7
View File
@@ -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;
};
+7
View File
@@ -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),
};
};
+5 -21
View File
@@ -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),
};
};
+1 -1
View File
@@ -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>
@@ -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>
+7
View File
@@ -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",
+1
View File
@@ -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))",