mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: show warning on unrecognized idp group and role mapping claims (#16485)
This commit is contained in:
+1
-1
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user