feat: show warning on unrecognized idp group and role mapping claims (#16485)

This commit is contained in:
ケイラ
2025-02-07 11:41:22 -07:00
committed by GitHub
parent 8a3a79f527
commit 323559ba82
9 changed files with 191 additions and 65 deletions
+1 -1
View File
@@ -804,7 +804,7 @@ class ApiMethods {
) => {
const params = new URLSearchParams();
params.set("claimField", field);
const response = await this.axios.get<TypesGen.Response>(
const response = await this.axios.get<readonly string[]>(
`/api/v2/organizations/${organization}/settings/idpsync/field-values?${params}`,
);
return response.data;
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { expect, userEvent, within } from "@storybook/test";
import {
MockOrganization,
MockOrganization2,
@@ -45,10 +45,16 @@ export const MissingGroups: Story = {
},
};
export const MissingClaim: Story = {
export const MissingClaims: Story = {
args: {
claimFieldValues: [],
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const warning = canvasElement.querySelector(".lucide-triangle-alert")!;
expect(warning).not.toBe(null);
await user.hover(warning);
},
};
export const AssignDefaultOrgWarningDialog: Story = {
@@ -1,4 +1,3 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import type {
Organization,
OrganizationSyncSettings,
@@ -30,7 +29,6 @@ import {
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { Switch } from "components/Switch/Switch";
import {
Table,
@@ -42,6 +40,7 @@ import {
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
@@ -22,8 +22,14 @@ import {
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
import { Plus, Trash } from "lucide-react";
import { Plus, Trash, TriangleAlert } from "lucide-react";
import { type FC, useId, useState } from "react";
import { docs } from "utils/docs";
import { isUUID } from "utils/uuid";
@@ -32,16 +38,6 @@ import { ExportPolicyButton } from "./ExportPolicyButton";
import { IdpMappingTable } from "./IdpMappingTable";
import { IdpPillList } from "./IdpPillList";
interface IdpGroupSyncFormProps {
groupSyncSettings: GroupSyncSettings;
groupsMap: Map<string, string>;
groups: Group[];
groupMappingCount: number;
legacyGroupMappingCount: number;
organization: Organization;
onSubmit: (data: GroupSyncSettings) => void;
}
const groupSyncValidationSchema = Yup.object({
field: Yup.string().trim(),
regex_filter: Yup.string().trim(),
@@ -65,15 +61,27 @@ const groupSyncValidationSchema = Yup.object({
.default({}),
});
export const IdpGroupSyncForm = ({
interface IdpGroupSyncFormProps {
groupSyncSettings: GroupSyncSettings;
claimFieldValues: readonly string[] | undefined;
groupsMap: Map<string, string>;
groups: Group[];
groupMappingCount: number;
legacyGroupMappingCount: number;
organization: Organization;
onSubmit: (data: GroupSyncSettings) => void;
}
export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
groupSyncSettings,
claimFieldValues,
groupMappingCount,
legacyGroupMappingCount,
groups,
groupsMap,
organization,
onSubmit,
}: IdpGroupSyncFormProps) => {
}) => {
const form = useFormik<GroupSyncSettings>({
initialValues: {
field: groupSyncSettings?.field ?? "",
@@ -270,6 +278,7 @@ export const IdpGroupSyncForm = ({
<GroupRow
key={idpGroup}
idpGroup={idpGroup}
exists={claimFieldValues?.includes(idpGroup)}
coderGroup={getGroupNames(groups)}
onDelete={handleDelete}
/>
@@ -288,6 +297,7 @@ export const IdpGroupSyncForm = ({
<GroupRow
key={groupId}
idpGroup={idpGroup}
exists={claimFieldValues?.includes(idpGroup)}
coderGroup={getGroupNames([groupId])}
onDelete={handleDelete}
/>
@@ -303,17 +313,48 @@ export const IdpGroupSyncForm = ({
interface GroupRowProps {
idpGroup: string;
exists: boolean | undefined;
coderGroup: readonly string[];
onDelete: (idpOrg: string) => void;
}
const GroupRow: FC<GroupRowProps> = ({ idpGroup, coderGroup, onDelete }) => {
const GroupRow: FC<GroupRowProps> = ({
idpGroup,
exists = true,
coderGroup,
onDelete,
}) => {
return (
<TableRow data-testid={`group-${idpGroup}`}>
<TableCell>{idpGroup}</TableCell>
<TableCell>
<div className="flex flex-row items-center gap-2 text-content-primary">
{idpGroup}
{!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>
<IdpPillList roles={coderGroup} />
</TableCell>
<TableCell>
<Button
variant="outline"
@@ -9,22 +9,20 @@ import {
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useFormik } from "formik";
import { Plus, Trash } from "lucide-react";
import { Plus, Trash, TriangleAlert } from "lucide-react";
import { type FC, useId, useState } from "react";
import * as Yup from "yup";
import { ExportPolicyButton } from "./ExportPolicyButton";
import { IdpMappingTable } from "./IdpMappingTable";
import { IdpPillList } from "./IdpPillList";
interface IdpRoleSyncFormProps {
roleSyncSettings: RoleSyncSettings;
roleMappingCount: number;
organization: Organization;
roles: Role[];
onSubmit: (data: RoleSyncSettings) => void;
}
const roleSyncValidationSchema = Yup.object({
field: Yup.string().trim(),
regex_filter: Yup.string().trim(),
@@ -48,13 +46,23 @@ const roleSyncValidationSchema = Yup.object({
.default({}),
});
export const IdpRoleSyncForm = ({
interface IdpRoleSyncFormProps {
roleSyncSettings: RoleSyncSettings;
claimFieldValues: readonly string[] | undefined;
roleMappingCount: number;
organization: Organization;
roles: Role[];
onSubmit: (data: RoleSyncSettings) => void;
}
export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
roleSyncSettings,
claimFieldValues,
roleMappingCount,
organization,
roles,
onSubmit,
}: IdpRoleSyncFormProps) => {
}) => {
const form = useFormik<RoleSyncSettings>({
initialValues: {
field: roleSyncSettings?.field ?? "",
@@ -210,6 +218,7 @@ export const IdpRoleSyncForm = ({
<RoleRow
key={idpRole}
idpRole={idpRole}
exists={claimFieldValues?.includes(idpRole)}
coderRoles={roles}
onDelete={handleDelete}
/>
@@ -222,17 +231,48 @@ export const IdpRoleSyncForm = ({
interface RoleRowProps {
idpRole: string;
exists: boolean | undefined;
coderRoles: readonly string[];
onDelete: (idpOrg: string) => void;
}
const RoleRow: FC<RoleRowProps> = ({ idpRole, coderRoles, onDelete }) => {
const RoleRow: FC<RoleRowProps> = ({
idpRole,
exists = true,
coderRoles,
onDelete,
}) => {
return (
<TableRow data-testid={`role-${idpRole}`}>
<TableCell>{idpRole}</TableCell>
<TableCell>
<div className="flex flex-row items-center gap-2 text-content-primary">
{idpRole}
{!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>
<IdpPillList roles={coderRoles} />
</TableCell>
<TableCell>
<Button
variant="outline"
@@ -2,6 +2,7 @@ import { getErrorMessage } from "api/errors";
import { groupsByOrganization } from "api/queries/groups";
import {
groupIdpSyncSettings,
organizationIdpSyncClaimFieldValues,
patchGroupSyncSettings,
patchRoleSyncSettings,
roleIdpSyncSettings,
@@ -17,8 +18,8 @@ import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQueries, useQueryClient } from "react-query";
import { useParams } from "react-router-dom";
import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
import { useParams, useSearchParams } from "react-router-dom";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import IdpSyncPageView from "./IdpSyncPageView";
@@ -47,6 +48,19 @@ export const IdpSyncPage: FC = () => {
],
});
const [searchParams] = useSearchParams();
const tab = searchParams.get("tab") || "groups";
const field =
tab === "groups"
? groupIdpSyncSettingsQuery.data?.field
: roleIdpSyncSettingsQuery.data?.field;
const fieldValuesQuery = useQuery(
field
? organizationIdpSyncClaimFieldValues(organizationName, field)
: { enabled: false },
);
if (!organization) {
return <EmptyState message="Organization not found" />;
}
@@ -99,8 +113,10 @@ export const IdpSyncPage: FC = () => {
</Cond>
<Cond>
<IdpSyncPageView
tab={tab}
groupSyncSettings={groupIdpSyncSettingsQuery.data}
roleSyncSettings={roleIdpSyncSettingsQuery.data}
claimFieldValues={fieldValuesQuery.data}
groups={groupsQuery.data}
groupsMap={groupsMap}
roles={rolesQuery.data}
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import { expect, userEvent } from "@storybook/test";
import {
MockGroup,
MockGroup2,
@@ -11,20 +11,32 @@ import {
} from "testHelpers/entities";
import { IdpSyncPageView } from "./IdpSyncPageView";
const groupsMap = new Map<string, string>();
for (const group of [MockGroup, MockGroup2]) {
groupsMap.set(group.id, group.display_name || group.name);
}
const meta: Meta<typeof IdpSyncPageView> = {
title: "pages/IdpSyncPage",
component: IdpSyncPageView,
args: {
tab: "groups",
groupSyncSettings: MockGroupSyncSettings,
roleSyncSettings: MockRoleSyncSettings,
claimFieldValues: [
...Object.keys(MockGroupSyncSettings.mapping),
...Object.keys(MockRoleSyncSettings.mapping),
],
groups: [MockGroup, MockGroup2],
groupsMap,
organization: MockOrganization,
error: undefined,
},
};
export default meta;
type Story = StoryObj<typeof IdpSyncPageView>;
const groupsMap = new Map<string, string>();
for (const group of [MockGroup, MockGroup2]) {
groupsMap.set(group.id, group.display_name || group.name);
}
export const Empty: Story = {
args: {
groupSyncSettings: {
@@ -44,47 +56,56 @@ export const Empty: Story = {
},
};
export const Default: Story = {
args: {
groupSyncSettings: MockGroupSyncSettings,
roleSyncSettings: MockRoleSyncSettings,
groups: [MockGroup, MockGroup2],
groupsMap,
organization: MockOrganization,
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,
groupSyncSettings: MockGroupSyncSettings2,
},
};
export const WithLegacyMapping: Story = {
args: {
...Default.args,
groupSyncSettings: MockLegacyMappingGroupSyncSettings,
claimFieldValues: Object.keys(
MockLegacyMappingGroupSyncSettings.legacy_group_name_mapping,
),
},
};
export const GroupsTabMissingClaims: Story = {
args: {
claimFieldValues: [],
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const warning = canvasElement.querySelector(".lucide-triangle-alert")!;
expect(warning).not.toBe(null);
await user.hover(warning);
},
};
export const RolesTab: Story = {
args: {
...Default.args,
tab: "roles",
},
};
export const RolesTabMissingClaims: Story = {
args: {
tab: "roles",
claimFieldValues: [],
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const rolesTab = await canvas.findByText("Role sync settings");
await user.click(rolesTab);
await expect(canvas.findByText("IdP role")).resolves.toBeVisible();
const warning = canvasElement.querySelector(".lucide-triangle-alert")!;
expect(warning).not.toBe(null);
await user.hover(warning);
},
};
@@ -9,13 +9,14 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import type { FC } from "react";
import { useSearchParams } from "react-router-dom";
import { IdpGroupSyncForm } from "./IdpGroupSyncForm";
import { IdpRoleSyncForm } from "./IdpRoleSyncForm";
interface IdpSyncPageViewProps {
tab: string;
groupSyncSettings: GroupSyncSettings | undefined;
roleSyncSettings: RoleSyncSettings | undefined;
claimFieldValues: readonly string[] | undefined;
groups: Group[] | undefined;
groupsMap: Map<string, string>;
roles: Role[] | undefined;
@@ -26,8 +27,10 @@ interface IdpSyncPageViewProps {
}
export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
tab,
groupSyncSettings,
roleSyncSettings,
claimFieldValues,
groups,
groupsMap,
roles,
@@ -36,8 +39,6 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
onSubmitGroupSyncSettings,
onSubmitRoleSyncSettings,
}) => {
const [searchParams] = useSearchParams();
const tab = searchParams.get("tab") || "groups";
const groupMappingCount = groupSyncSettings?.mapping
? Object.entries(groupSyncSettings.mapping).length
: 0;
@@ -68,6 +69,7 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
{tab === "groups" ? (
<IdpGroupSyncForm
groupSyncSettings={groupSyncSettings}
claimFieldValues={claimFieldValues}
groupMappingCount={groupMappingCount}
legacyGroupMappingCount={legacyGroupMappingCount}
groups={groups}
@@ -78,6 +80,7 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
) : (
<IdpRoleSyncForm
roleSyncSettings={roleSyncSettings}
claimFieldValues={claimFieldValues}
roleMappingCount={roleMappingCount}
roles={roles || []}
organization={organization}
+2 -2
View File
@@ -2668,14 +2668,14 @@ export const MockGroupSyncSettings: TypesGen.GroupSyncSettings = {
auto_create_missing_groups: false,
};
export const MockLegacyMappingGroupSyncSettings: TypesGen.GroupSyncSettings = {
export const MockLegacyMappingGroupSyncSettings = {
...MockGroupSyncSettings,
mapping: {},
legacy_group_name_mapping: {
"idp-group-1": "fbd2116a-8961-4954-87ae-e4575bd29ce0",
"idp-group-2": "13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
},
};
} satisfies TypesGen.GroupSyncSettings;
export const MockGroupSyncSettings2: TypesGen.GroupSyncSettings = {
field: "group-test",