mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: integrate backend with idp sync page (#14755)
* feat: idp sync initial commit * fix: hookup backend data for groups and roles * chore: cleanup * feat: separate groups and roles into tabs * feat: implement export policy button * feat: handle missing groups * chore: add story for missing groups * chore: add stories for export policy button * fix: updates for PR review * chore: update tests * chore: document uuid regex * chore: remove unused * fix: fix stories
This commit is contained in:
@@ -704,6 +704,30 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param organization Can be the organization's ID or name
|
||||
*/
|
||||
getGroupIdpSyncSettingsByOrganization = async (
|
||||
organization: string,
|
||||
): Promise<TypesGen.GroupSyncSettings> => {
|
||||
const response = await this.axios.get<TypesGen.GroupSyncSettings>(
|
||||
`/api/v2/organizations/${organization}/settings/idpsync/groups`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param organization Can be the organization's ID or name
|
||||
*/
|
||||
getRoleIdpSyncSettingsByOrganization = async (
|
||||
organization: string,
|
||||
): Promise<TypesGen.RoleSyncSettings> => {
|
||||
const response = await this.axios.get<TypesGen.RoleSyncSettings>(
|
||||
`/api/v2/organizations/${organization}/settings/idpsync/roles`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
|
||||
const response = await this.axios.get<TypesGen.Template>(
|
||||
`/api/v2/templates/${templateId}`,
|
||||
|
||||
@@ -141,6 +141,32 @@ export const provisionerDaemonGroups = (organization: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getGroupIdpSyncSettingsKey = (organization: string) => [
|
||||
"organizations",
|
||||
organization,
|
||||
"groupIdpSyncSettings",
|
||||
];
|
||||
|
||||
export const groupIdpSyncSettings = (organization: string) => {
|
||||
return {
|
||||
queryKey: getGroupIdpSyncSettingsKey(organization),
|
||||
queryFn: () => API.getGroupIdpSyncSettingsByOrganization(organization),
|
||||
};
|
||||
};
|
||||
|
||||
export const getRoleIdpSyncSettingsKey = (organization: string) => [
|
||||
"organizations",
|
||||
organization,
|
||||
"roleIdpSyncSettings",
|
||||
];
|
||||
|
||||
export const roleIdpSyncSettings = (organization: string) => {
|
||||
return {
|
||||
queryKey: getRoleIdpSyncSettingsKey(organization),
|
||||
queryFn: () => API.getRoleIdpSyncSettingsByOrganization(organization),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch permissions for a single organization.
|
||||
*
|
||||
@@ -243,6 +269,13 @@ export const organizationsPermissions = (
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
viewIdpSyncSettings: {
|
||||
object: {
|
||||
resource_type: "idpsync_settings",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
});
|
||||
|
||||
// The endpoint takes a flat array, so to avoid collisions prepend each
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ export const AppearanceSettingsPageView: FC<
|
||||
<PopoverContent css={{ transform: "translateY(-28px)" }}>
|
||||
<PopoverPaywall
|
||||
message="Appearance"
|
||||
description="With a Premium license, you can customize the appearance of your deployment."
|
||||
description="With a Premium license, you can customize the appearance and branding of your deployment."
|
||||
documentationLink="https://coder.com/docs/admin/appearance"
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -29,12 +29,12 @@ import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import { docs } from "utils/docs";
|
||||
import { PermissionPillsList } from "./PermissionPillsList";
|
||||
|
||||
export type CustomRolesPageViewProps = {
|
||||
interface CustomRolesPageViewProps {
|
||||
roles: Role[] | undefined;
|
||||
onDeleteRole: (role: Role) => void;
|
||||
canAssignOrgRole: boolean;
|
||||
isCustomRolesEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
|
||||
roles,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
|
||||
import {
|
||||
MockGroupSyncSettings,
|
||||
MockOrganization,
|
||||
MockRoleSyncSettings,
|
||||
} from "testHelpers/entities";
|
||||
import { ExportPolicyButton } from "./ExportPolicyButton";
|
||||
|
||||
const meta: Meta<typeof ExportPolicyButton> = {
|
||||
title: "modules/resources/ExportPolicyButton",
|
||||
component: ExportPolicyButton,
|
||||
args: {
|
||||
syncSettings: MockGroupSyncSettings,
|
||||
type: "groups",
|
||||
organization: MockOrganization,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ExportPolicyButton>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const ClickExportGroupPolicy: Story = {
|
||||
args: {
|
||||
syncSettings: MockGroupSyncSettings,
|
||||
type: "groups",
|
||||
organization: MockOrganization,
|
||||
download: fn(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Export Policy" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(args.download).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
`${MockOrganization.name}_groups-policy.json`,
|
||||
),
|
||||
);
|
||||
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
|
||||
await expect(blob.type).toEqual("application/json");
|
||||
await expect(await blob.text()).toEqual(
|
||||
JSON.stringify(MockGroupSyncSettings, null, 2),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ClickExportRolePolicy: Story = {
|
||||
args: {
|
||||
syncSettings: MockRoleSyncSettings,
|
||||
type: "roles",
|
||||
organization: MockOrganization,
|
||||
download: fn(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Export Policy" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(args.download).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
`${MockOrganization.name}_roles-policy.json`,
|
||||
),
|
||||
);
|
||||
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
|
||||
await expect(blob.type).toEqual("application/json");
|
||||
await expect(await blob.text()).toEqual(
|
||||
JSON.stringify(MockRoleSyncSettings, null, 2),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import type {
|
||||
GroupSyncSettings,
|
||||
Organization,
|
||||
RoleSyncSettings,
|
||||
} from "api/typesGenerated";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { saveAs } from "file-saver";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
|
||||
interface DownloadPolicyButtonProps {
|
||||
syncSettings: RoleSyncSettings | GroupSyncSettings | undefined;
|
||||
type: "groups" | "roles";
|
||||
organization: Organization;
|
||||
download?: (file: Blob, filename: string) => void;
|
||||
}
|
||||
|
||||
export const ExportPolicyButton: FC<DownloadPolicyButtonProps> = ({
|
||||
syncSettings,
|
||||
type,
|
||||
organization,
|
||||
download = saveAs,
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const policyJSON = useMemo(() => {
|
||||
return syncSettings?.field && syncSettings.mapping
|
||||
? JSON.stringify(syncSettings, null, 2)
|
||||
: null;
|
||||
}, [syncSettings]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
startIcon={<DownloadOutlined />}
|
||||
disabled={!policyJSON || isDownloading}
|
||||
onClick={async () => {
|
||||
if (policyJSON) {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
const file = new Blob([policyJSON], {
|
||||
type: "application/json",
|
||||
});
|
||||
download(file, `${organization.name}_${type}-policy.json`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
displayError("Failed to export policy json");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Export Policy
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import type { FC } from "react";
|
||||
|
||||
interface PillListProps {
|
||||
roles: readonly string[];
|
||||
}
|
||||
|
||||
// used to check if the role is a UUID
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const IdpPillList: FC<PillListProps> = ({ roles }) => {
|
||||
return (
|
||||
<Stack direction="row" spacing={1}>
|
||||
{roles.length > 0 ? (
|
||||
<Pill css={UUID.test(roles[0]) ? styles.errorPill : styles.pill}>
|
||||
{roles[0]}
|
||||
</Pill>
|
||||
) : (
|
||||
<p>None</p>
|
||||
)}
|
||||
|
||||
{roles.length > 1 && <OverflowPill roles={roles.slice(1)} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface OverflowPillProps {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const OverflowPill: FC<OverflowPillProps> = ({ roles }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Popover mode="hover">
|
||||
<PopoverTrigger>
|
||||
<Pill
|
||||
css={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderColor: theme.palette.divider,
|
||||
}}
|
||||
data-testid="overflow-pill"
|
||||
>
|
||||
+{roles.length} more
|
||||
</Pill>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
disableRestoreFocus
|
||||
disableScrollLock
|
||||
css={{
|
||||
".MuiPaper-root": {
|
||||
display: "flex",
|
||||
flexFlow: "column wrap",
|
||||
columnGap: 8,
|
||||
rowGap: 12,
|
||||
padding: "12px 16px",
|
||||
alignContent: "space-around",
|
||||
minWidth: "auto",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: -4,
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<Pill
|
||||
key={role}
|
||||
css={UUID.test(role) ? styles.errorPill : styles.pill}
|
||||
>
|
||||
{role}
|
||||
</Pill>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
pill: (theme) => ({
|
||||
backgroundColor: theme.experimental.pillDefault.background,
|
||||
borderColor: theme.experimental.pillDefault.outline,
|
||||
color: theme.experimental.pillDefault.text,
|
||||
width: "fit-content",
|
||||
}),
|
||||
errorPill: (theme) => ({
|
||||
backgroundColor: theme.roles.error.background,
|
||||
borderColor: theme.roles.error.outline,
|
||||
color: theme.roles.error.text,
|
||||
width: "fit-content",
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -1,63 +1,73 @@
|
||||
import AddIcon from "@mui/icons-material/AddOutlined";
|
||||
import LaunchOutlined from "@mui/icons-material/LaunchOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import { groupsByOrganization } from "api/queries/groups";
|
||||
import {
|
||||
groupIdpSyncSettings,
|
||||
roleIdpSyncSettings,
|
||||
} from "api/queries/organizations";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useQueries } from "react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { docs } from "utils/docs";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { useOrganizationSettings } from "../ManagementSettingsLayout";
|
||||
import { IdpSyncHelpTooltip } from "./IdpSyncHelpTooltip";
|
||||
import IdpSyncPageView from "./IdpSyncPageView";
|
||||
|
||||
const mockOIDCConfig = {
|
||||
allow_signups: true,
|
||||
client_id: "test",
|
||||
client_secret: "test",
|
||||
client_key_file: "test",
|
||||
client_cert_file: "test",
|
||||
email_domain: [],
|
||||
issuer_url: "test",
|
||||
scopes: [],
|
||||
ignore_email_verified: true,
|
||||
username_field: "",
|
||||
name_field: "",
|
||||
email_field: "",
|
||||
auth_url_params: {},
|
||||
ignore_user_info: true,
|
||||
organization_field: "",
|
||||
organization_mapping: {},
|
||||
organization_assign_default: true,
|
||||
group_auto_create: false,
|
||||
group_regex_filter: "^Coder-.*$",
|
||||
group_allow_list: [],
|
||||
groups_field: "groups",
|
||||
group_mapping: { group1: "developers", group2: "admin", group3: "auditors" },
|
||||
user_role_field: "roles",
|
||||
user_role_mapping: { role1: ["role1", "role2"] },
|
||||
user_roles_default: [],
|
||||
sign_in_text: "",
|
||||
icon_url: "",
|
||||
signups_disabled_text: "string",
|
||||
skip_issuer_checks: true,
|
||||
};
|
||||
|
||||
export const IdpSyncPage: FC = () => {
|
||||
// feature visibility and permissions to be implemented when integrating with backend
|
||||
// const feats = useFeatureVisibility();
|
||||
// const { organization: organizationName } = useParams() as {
|
||||
// organization: string;
|
||||
// };
|
||||
// const { organizations } = useOrganizationSettings();
|
||||
// const organization = organizations?.find((o) => o.name === organizationName);
|
||||
// const permissionsQuery = useQuery(organizationPermissions(organization?.id));
|
||||
// const permissions = permissionsQuery.data;
|
||||
const { organization: organizationName } = useParams() as {
|
||||
organization: string;
|
||||
};
|
||||
const { organizations } = useOrganizationSettings();
|
||||
const organization = organizations?.find((o) => o.name === organizationName);
|
||||
|
||||
// if (!permissions) {
|
||||
// return <Loader />;
|
||||
// }
|
||||
const [groupIdpSyncSettingsQuery, roleIdpSyncSettingsQuery, groupsQuery] =
|
||||
useQueries({
|
||||
queries: [
|
||||
groupIdpSyncSettings(organizationName),
|
||||
roleIdpSyncSettings(organizationName),
|
||||
groupsByOrganization(organizationName),
|
||||
],
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
if (
|
||||
groupsQuery.isLoading ||
|
||||
groupIdpSyncSettingsQuery.isLoading ||
|
||||
roleIdpSyncSettingsQuery.isLoading
|
||||
) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const error =
|
||||
groupIdpSyncSettingsQuery.error ||
|
||||
roleIdpSyncSettingsQuery.error ||
|
||||
groupsQuery.error;
|
||||
if (
|
||||
error ||
|
||||
!groupIdpSyncSettingsQuery.data ||
|
||||
!roleIdpSyncSettingsQuery.data ||
|
||||
!groupsQuery.data
|
||||
) {
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
const groupsMap = new Map<string, string>();
|
||||
if (groupsQuery.data) {
|
||||
for (const group of groupsQuery.data) {
|
||||
groupsMap.set(group.id, group.display_name || group.name);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -72,7 +82,7 @@ export const IdpSyncPage: FC = () => {
|
||||
>
|
||||
<SettingsHeader
|
||||
title="IdP Sync"
|
||||
description="Group and role sync mappings (configured outside Coder)."
|
||||
description="Group and role sync mappings (configured using Coder CLI)."
|
||||
tooltip={<IdpSyncHelpTooltip />}
|
||||
badges={<FeatureStageBadge contentType="beta" size="lg" />}
|
||||
/>
|
||||
@@ -85,13 +95,16 @@ export const IdpSyncPage: FC = () => {
|
||||
>
|
||||
Setup IdP Sync
|
||||
</Button>
|
||||
<Button component={RouterLink} startIcon={<AddIcon />} to="export">
|
||||
Export Policy
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<IdpSyncPageView oidcConfig={mockOIDCConfig} />
|
||||
<IdpSyncPageView
|
||||
groupSyncSettings={groupIdpSyncSettingsQuery.data}
|
||||
roleSyncSettings={roleIdpSyncSettingsQuery.data}
|
||||
groups={groupsQuery.data}
|
||||
groupsMap={groupsMap}
|
||||
organization={organization}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockOIDCConfig } from "testHelpers/entities";
|
||||
import {
|
||||
MockGroup,
|
||||
MockGroup2,
|
||||
MockGroupSyncSettings,
|
||||
MockGroupSyncSettings2,
|
||||
MockRoleSyncSettings,
|
||||
} from "testHelpers/entities";
|
||||
import { IdpSyncPageView } from "./IdpSyncPageView";
|
||||
|
||||
const meta: Meta<typeof IdpSyncPageView> = {
|
||||
@@ -10,10 +16,32 @@ const meta: Meta<typeof IdpSyncPageView> = {
|
||||
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: { oidcConfig: undefined },
|
||||
args: {
|
||||
groupSyncSettings: undefined,
|
||||
roleSyncSettings: undefined,
|
||||
groupsMap: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: { oidcConfig: MockOIDCConfig },
|
||||
args: {
|
||||
groupSyncSettings: MockGroupSyncSettings,
|
||||
roleSyncSettings: MockRoleSyncSettings,
|
||||
groupsMap,
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingGroups: Story = {
|
||||
args: {
|
||||
groupSyncSettings: MockGroupSyncSettings2,
|
||||
roleSyncSettings: MockRoleSyncSettings,
|
||||
groupsMap,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import LaunchOutlined from "@mui/icons-material/LaunchOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
@@ -9,7 +8,12 @@ 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 { OIDCConfig } from "api/typesGenerated";
|
||||
import type {
|
||||
Group,
|
||||
GroupSyncSettings,
|
||||
Organization,
|
||||
RoleSyncSettings,
|
||||
} from "api/typesGenerated";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
@@ -19,22 +23,43 @@ import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
|
||||
import type { FC } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
import { docs } from "utils/docs";
|
||||
import { ExportPolicyButton } from "./ExportPolicyButton";
|
||||
import { IdpPillList } from "./IdpPillList";
|
||||
|
||||
export type IdpSyncPageViewProps = {
|
||||
oidcConfig: OIDCConfig | undefined;
|
||||
};
|
||||
interface IdpSyncPageViewProps {
|
||||
groupSyncSettings: GroupSyncSettings | undefined;
|
||||
roleSyncSettings: RoleSyncSettings | undefined;
|
||||
groups: Group[] | undefined;
|
||||
groupsMap: Map<string, string>;
|
||||
organization: Organization;
|
||||
}
|
||||
|
||||
export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
|
||||
groupSyncSettings,
|
||||
roleSyncSettings,
|
||||
groupsMap,
|
||||
organization,
|
||||
}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const getGroupNames = (groupIds: readonly string[]) => {
|
||||
return groupIds.map((groupId) => groupsMap.get(groupId) || groupId);
|
||||
};
|
||||
|
||||
const tab = searchParams.get("tab") || "groups";
|
||||
|
||||
const groupMappingCount = groupSyncSettings?.mapping
|
||||
? Object.entries(groupSyncSettings.mapping).length
|
||||
: 0;
|
||||
const roleMappingCount = roleSyncSettings?.mapping
|
||||
? Object.entries(roleSyncSettings.mapping).length
|
||||
: 0;
|
||||
|
||||
export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({ oidcConfig }) => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
groups_field,
|
||||
user_role_field,
|
||||
group_regex_filter,
|
||||
group_auto_create,
|
||||
} = oidcConfig || {};
|
||||
return (
|
||||
<>
|
||||
<ChooseOne>
|
||||
@@ -46,75 +71,112 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({ oidcConfig }) => {
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<Stack spacing={2} css={styles.fields}>
|
||||
{/* Semantically fieldset is used for forms. In the future this screen will allow
|
||||
updates to these fields in a form */}
|
||||
<fieldset css={styles.box}>
|
||||
<legend css={styles.legend}>Groups</legend>
|
||||
<Stack direction={"row"} alignItems={"center"} spacing={8}>
|
||||
<IdpField
|
||||
name={"Sync Field"}
|
||||
fieldText={groups_field}
|
||||
showStatusIndicator
|
||||
/>
|
||||
<IdpField
|
||||
name={"Regex Filter"}
|
||||
fieldText={group_regex_filter}
|
||||
/>
|
||||
<IdpField
|
||||
name={"Auto Create"}
|
||||
fieldText={group_auto_create?.toString()}
|
||||
/>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
<fieldset css={styles.box}>
|
||||
<legend css={styles.legend}>Roles</legend>
|
||||
<Stack direction={"row"} alignItems={"center"} spacing={3}>
|
||||
<IdpField
|
||||
name={"Sync Field"}
|
||||
fieldText={user_role_field}
|
||||
showStatusIndicator
|
||||
/>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</Stack>
|
||||
<Stack spacing={6}>
|
||||
<IdpMappingTable
|
||||
type="Role"
|
||||
isEmpty={Boolean(
|
||||
!oidcConfig?.user_role_mapping ||
|
||||
Object.entries(oidcConfig?.user_role_mapping).length === 0,
|
||||
)}
|
||||
>
|
||||
{oidcConfig?.user_role_mapping &&
|
||||
Object.entries(oidcConfig.user_role_mapping)
|
||||
.sort()
|
||||
.map(([idpRole, roles]) => (
|
||||
<RoleRow
|
||||
key={idpRole}
|
||||
idpRole={idpRole}
|
||||
coderRoles={roles}
|
||||
<Stack spacing={2}>
|
||||
<Tabs active={tab}>
|
||||
<TabsList>
|
||||
<TabLink to="?tab=groups" value="groups">
|
||||
Group Sync Settings
|
||||
</TabLink>
|
||||
<TabLink to="?tab=roles" value="roles">
|
||||
Role Sync Settings
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{tab === "groups" ? (
|
||||
<>
|
||||
<div css={styles.fields}>
|
||||
<Stack direction={"row"} alignItems={"center"} spacing={6}>
|
||||
<IdpField
|
||||
name={"Sync Field"}
|
||||
fieldText={groupSyncSettings?.field}
|
||||
showDisabled
|
||||
/>
|
||||
))}
|
||||
</IdpMappingTable>
|
||||
<IdpMappingTable
|
||||
type="Group"
|
||||
isEmpty={Boolean(
|
||||
!oidcConfig?.group_mapping ||
|
||||
Object.entries(oidcConfig?.group_mapping).length === 0,
|
||||
)}
|
||||
>
|
||||
{oidcConfig?.user_role_mapping &&
|
||||
Object.entries(oidcConfig.group_mapping)
|
||||
.sort()
|
||||
.map(([idpGroup, group]) => (
|
||||
<GroupRow
|
||||
key={idpGroup}
|
||||
idpGroup={idpGroup}
|
||||
coderGroup={group}
|
||||
<IdpField
|
||||
name={"Regex Filter"}
|
||||
fieldText={
|
||||
typeof groupSyncSettings?.regex_filter === "string"
|
||||
? groupSyncSettings.regex_filter
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</IdpMappingTable>
|
||||
<IdpField
|
||||
name={"Auto Create"}
|
||||
fieldText={String(
|
||||
groupSyncSettings?.auto_create_missing_groups || "n/a",
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
css={styles.tableInfo}
|
||||
>
|
||||
<TableRowCount count={groupMappingCount} type="groups" />
|
||||
<ExportPolicyButton
|
||||
syncSettings={groupSyncSettings}
|
||||
organization={organization}
|
||||
type="groups"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack spacing={6}>
|
||||
<IdpMappingTable
|
||||
type="Group"
|
||||
isEmpty={Boolean(groupMappingCount === 0)}
|
||||
>
|
||||
{groupSyncSettings?.mapping &&
|
||||
Object.entries(groupSyncSettings.mapping)
|
||||
.sort()
|
||||
.map(([idpGroup, groups]) => (
|
||||
<GroupRow
|
||||
key={idpGroup}
|
||||
idpGroup={idpGroup}
|
||||
coderGroup={getGroupNames(groups)}
|
||||
/>
|
||||
))}
|
||||
</IdpMappingTable>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div css={styles.fields}>
|
||||
<IdpField
|
||||
name={"Sync Field"}
|
||||
fieldText={roleSyncSettings?.field}
|
||||
showDisabled
|
||||
/>
|
||||
</div>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
css={styles.tableInfo}
|
||||
>
|
||||
<TableRowCount count={roleMappingCount} type="roles" />
|
||||
<ExportPolicyButton
|
||||
syncSettings={roleSyncSettings}
|
||||
organization={organization}
|
||||
type="roles"
|
||||
/>
|
||||
</Stack>
|
||||
<IdpMappingTable
|
||||
type="Role"
|
||||
isEmpty={Boolean(roleMappingCount === 0)}
|
||||
>
|
||||
{roleSyncSettings?.mapping &&
|
||||
Object.entries(roleSyncSettings.mapping)
|
||||
.sort()
|
||||
.map(([idpRole, roles]) => (
|
||||
<RoleRow
|
||||
key={idpRole}
|
||||
idpRole={idpRole}
|
||||
coderRoles={roles}
|
||||
/>
|
||||
))}
|
||||
</IdpMappingTable>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
@@ -125,37 +187,66 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({ oidcConfig }) => {
|
||||
interface IdpFieldProps {
|
||||
name: string;
|
||||
fieldText: string | undefined;
|
||||
showStatusIndicator?: boolean;
|
||||
showDisabled?: boolean;
|
||||
}
|
||||
|
||||
const IdpField: FC<IdpFieldProps> = ({
|
||||
name,
|
||||
fieldText,
|
||||
showStatusIndicator = false,
|
||||
showDisabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<span css={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
<h4>{name}</h4>
|
||||
<p css={styles.field}>
|
||||
{fieldText ||
|
||||
(showStatusIndicator && (
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
height: 0,
|
||||
}}
|
||||
>
|
||||
<StatusIndicator color="error" />
|
||||
<p>disabled</p>
|
||||
</div>
|
||||
))}
|
||||
</p>
|
||||
<span
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<p css={styles.fieldLabel}>{name}</p>
|
||||
{fieldText ? (
|
||||
<p css={styles.fieldText}>{fieldText}</p>
|
||||
) : (
|
||||
showDisabled && (
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
height: 0,
|
||||
}}
|
||||
>
|
||||
<StatusIndicator color="error" />
|
||||
<p>disabled</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableRowCountProps {
|
||||
count: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const TableRowCount: FC<TableRowCountProps> = ({ count, type }) => {
|
||||
return (
|
||||
<div
|
||||
css={(theme) => ({
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
"& strong": {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
})}
|
||||
>
|
||||
Showing <strong>{count}</strong> {type}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IdpMappingTableProps {
|
||||
type: "Role" | "Group";
|
||||
isEmpty: boolean;
|
||||
@@ -194,7 +285,9 @@ const IdpMappingTable: FC<IdpMappingTableProps> = ({
|
||||
<Button
|
||||
startIcon={<LaunchOutlined />}
|
||||
component="a"
|
||||
href={docs("/admin/auth#group-sync-enterprise")}
|
||||
href={docs(
|
||||
`/admin/auth#${type.toLowerCase()}-sync-enterprise`,
|
||||
)}
|
||||
target="_blank"
|
||||
>
|
||||
How to setup IdP {type} sync
|
||||
@@ -215,28 +308,32 @@ const IdpMappingTable: FC<IdpMappingTableProps> = ({
|
||||
|
||||
interface GroupRowProps {
|
||||
idpGroup: string;
|
||||
coderGroup: string;
|
||||
coderGroup: readonly string[];
|
||||
}
|
||||
|
||||
const GroupRow: FC<GroupRowProps> = ({ idpGroup, coderGroup }) => {
|
||||
return (
|
||||
<TableRow data-testid={`group-${idpGroup}`}>
|
||||
<TableCell>{idpGroup}</TableCell>
|
||||
<TableCell>{coderGroup}</TableCell>
|
||||
<TableCell>
|
||||
<IdpPillList roles={coderGroup} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
interface RoleRowProps {
|
||||
idpRole: string;
|
||||
coderRoles: ReadonlyArray<string>;
|
||||
coderRoles: readonly string[];
|
||||
}
|
||||
|
||||
const RoleRow: FC<RoleRowProps> = ({ idpRole, coderRoles }) => {
|
||||
return (
|
||||
<TableRow data-testid={`role-${idpRole}`}>
|
||||
<TableCell>{idpRole}</TableCell>
|
||||
<TableCell>coderRoles Placeholder</TableCell>
|
||||
<TableCell>
|
||||
<IdpPillList roles={coderRoles} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -260,22 +357,20 @@ const TableLoader = () => {
|
||||
};
|
||||
|
||||
const styles = {
|
||||
field: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
fieldText: (theme) => ({
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
whiteSpace: "nowrap",
|
||||
paddingBottom: ".02rem",
|
||||
}),
|
||||
fieldLabel: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
fields: () => ({
|
||||
marginBottom: "60px",
|
||||
marginLeft: 16,
|
||||
fontSize: 14,
|
||||
}),
|
||||
legend: () => ({
|
||||
padding: "0px 6px",
|
||||
fontWeight: 600,
|
||||
}),
|
||||
box: (theme) => ({
|
||||
border: "1px solid",
|
||||
borderColor: theme.palette.divider,
|
||||
padding: "0px 20px",
|
||||
borderRadius: 8,
|
||||
tableInfo: () => ({
|
||||
marginBottom: 16,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ const OrganizationSettingsNavigation: FC<
|
||||
Provisioners
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{organization.permissions.editMembers && (
|
||||
{organization.permissions.viewIdpSyncSettings && (
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(organization.name, "idp-sync")}
|
||||
>
|
||||
|
||||
@@ -451,38 +451,6 @@ export const MockAssignableSiteRoles = [
|
||||
assignableRole(MockAuditorRole, true),
|
||||
];
|
||||
|
||||
export const MockOIDCConfig: TypesGen.OIDCConfig = {
|
||||
allow_signups: true,
|
||||
client_id: "test",
|
||||
client_secret: "test",
|
||||
client_key_file: "test",
|
||||
client_cert_file: "test",
|
||||
email_domain: [],
|
||||
issuer_url: "test",
|
||||
scopes: [],
|
||||
ignore_email_verified: true,
|
||||
username_field: "",
|
||||
name_field: "",
|
||||
email_field: "",
|
||||
auth_url_params: {},
|
||||
ignore_user_info: true,
|
||||
organization_field: "",
|
||||
organization_mapping: {},
|
||||
organization_assign_default: true,
|
||||
group_auto_create: false,
|
||||
group_regex_filter: "^Coder-.*$",
|
||||
group_allow_list: [],
|
||||
groups_field: "groups",
|
||||
group_mapping: { group1: "developers", group2: "admin", group3: "auditors" },
|
||||
user_role_field: "roles",
|
||||
user_role_mapping: { role1: ["role1", "role2"] },
|
||||
user_roles_default: [],
|
||||
sign_in_text: "",
|
||||
icon_url: "",
|
||||
signups_disabled_text: "string",
|
||||
skip_issuer_checks: true,
|
||||
};
|
||||
|
||||
export const MockMemberPermissions = {
|
||||
viewAuditLog: false,
|
||||
};
|
||||
@@ -2632,6 +2600,40 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
|
||||
budget: 100,
|
||||
};
|
||||
|
||||
export const MockGroupSyncSettings: TypesGen.GroupSyncSettings = {
|
||||
field: "group-test",
|
||||
mapping: {
|
||||
"idp-group-1": [
|
||||
"fbd2116a-8961-4954-87ae-e4575bd29ce0",
|
||||
"13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
|
||||
],
|
||||
"idp-group-2": ["fbd2116a-8961-4954-87ae-e4575bd29ce0"],
|
||||
},
|
||||
regex_filter: "@[a-zA-Z0-9_]+",
|
||||
auto_create_missing_groups: false,
|
||||
};
|
||||
|
||||
export const MockGroupSyncSettings2: TypesGen.GroupSyncSettings = {
|
||||
field: "group-test",
|
||||
mapping: {
|
||||
"idp-group-1": [
|
||||
"fbd2116a-8961-4954-87ae-e4575bd29ce0",
|
||||
"13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e3",
|
||||
],
|
||||
"idp-group-2": ["fbd2116a-8961-4954-87ae-e4575bd29ce2"],
|
||||
},
|
||||
regex_filter: "@[a-zA-Z0-9_]+",
|
||||
auto_create_missing_groups: false,
|
||||
};
|
||||
|
||||
export const MockRoleSyncSettings: TypesGen.RoleSyncSettings = {
|
||||
field: "role-test",
|
||||
mapping: {
|
||||
"idp-role-1": ["admin", "developer"],
|
||||
"idp-role-2": ["auditor"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MockGroup: TypesGen.Group = {
|
||||
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
|
||||
name: "Front-End",
|
||||
@@ -2646,6 +2648,20 @@ export const MockGroup: TypesGen.Group = {
|
||||
total_member_count: 2,
|
||||
};
|
||||
|
||||
export const MockGroup2: TypesGen.Group = {
|
||||
id: "13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
|
||||
name: "developer",
|
||||
display_name: "",
|
||||
avatar_url: "https://example.com",
|
||||
organization_id: MockOrganization.id,
|
||||
organization_name: MockOrganization.name,
|
||||
organization_display_name: MockOrganization.display_name,
|
||||
members: [MockUser, MockUser2],
|
||||
quota_allowance: 5,
|
||||
source: "user",
|
||||
total_member_count: 2,
|
||||
};
|
||||
|
||||
const MockEveryoneGroup: TypesGen.Group = {
|
||||
// The "Everyone" group must have the same ID as a the organization it belongs
|
||||
// to.
|
||||
|
||||
Reference in New Issue
Block a user