diff --git a/site/src/api/api.ts b/site/src/api/api.ts index e0781846ff4fe..103a3c50e7900 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -704,6 +704,30 @@ class ApiMethods { return response.data; }; + /** + * @param organization Can be the organization's ID or name + */ + getGroupIdpSyncSettingsByOrganization = async ( + organization: string, + ): Promise => { + const response = await this.axios.get( + `/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 => { + const response = await this.axios.get( + `/api/v2/organizations/${organization}/settings/idpsync/roles`, + ); + return response.data; + }; + getTemplate = async (templateId: string): Promise => { const response = await this.axios.get( `/api/v2/templates/${templateId}`, diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 8e1143800b869..d1df8f409dcdf 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -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 diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 0c5d470c027fe..2060c1b5f5660 100644 --- a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -74,7 +74,7 @@ export const AppearanceSettingsPageView: FC< diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 9be45d16318c0..70ac152652d84 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -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 = ({ roles, diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx new file mode 100644 index 0000000000000..da2f5140d3d73 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx @@ -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 = { + title: "modules/resources/ExportPolicyButton", + component: ExportPolicyButton, + args: { + syncSettings: MockGroupSyncSettings, + type: "groups", + organization: MockOrganization, + }, +}; + +export default meta; +type Story = StoryObj; + +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), + ); + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx new file mode 100644 index 0000000000000..9cb4cb06e7385 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx @@ -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 = ({ + 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 ( + + ); +}; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpPillList.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpPillList.tsx new file mode 100644 index 0000000000000..4f489747f0bba --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpPillList.tsx @@ -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 = ({ roles }) => { + return ( + + {roles.length > 0 ? ( + + {roles[0]} + + ) : ( +

None

+ )} + + {roles.length > 1 && } +
+ ); +}; + +interface OverflowPillProps { + roles: string[]; +} + +const OverflowPill: FC = ({ roles }) => { + const theme = useTheme(); + + return ( + + + + +{roles.length} more + + + + + {roles.map((role) => ( + + {role} + + ))} + + + ); +}; + +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>; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx index 93b4e59455409..42a700f926633 100644 --- a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -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); + + const [groupIdpSyncSettingsQuery, roleIdpSyncSettingsQuery, groupsQuery] = + useQueries({ + queries: [ + groupIdpSyncSettings(organizationName), + roleIdpSyncSettings(organizationName), + groupsByOrganization(organizationName), + ], + }); + + if (!organization) { + return ; + } - // if (!permissions) { - // return ; - // } + if ( + groupsQuery.isLoading || + groupIdpSyncSettingsQuery.isLoading || + roleIdpSyncSettingsQuery.isLoading + ) { + return ; + } + + const error = + groupIdpSyncSettingsQuery.error || + roleIdpSyncSettingsQuery.error || + groupsQuery.error; + if ( + error || + !groupIdpSyncSettingsQuery.data || + !roleIdpSyncSettingsQuery.data || + !groupsQuery.data + ) { + return ; + } + + const groupsMap = new Map(); + 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 = () => { > } badges={} /> @@ -85,13 +95,16 @@ export const IdpSyncPage: FC = () => { > Setup IdP Sync - - + ); }; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx index 47952edc61c95..646e64d763b86 100644 --- a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx @@ -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 = { @@ -10,10 +16,32 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const groupsMap = new Map(); + +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, + }, }; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx index 08c165d6c7e91..f73395e254e8b 100644 --- a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx @@ -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; + organization: Organization; +} + +export const IdpSyncPageView: FC = ({ + 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 = ({ oidcConfig }) => { - const theme = useTheme(); - const { - groups_field, - user_role_field, - group_regex_filter, - group_auto_create, - } = oidcConfig || {}; return ( <> @@ -46,75 +71,112 @@ export const IdpSyncPageView: FC = ({ oidcConfig }) => { /> - - {/* Semantically fieldset is used for forms. In the future this screen will allow - updates to these fields in a form */} -
- Groups - - - - - -
-
- Roles - - - -
-
- - - {oidcConfig?.user_role_mapping && - Object.entries(oidcConfig.user_role_mapping) - .sort() - .map(([idpRole, roles]) => ( - + + + + Group Sync Settings + + + Role Sync Settings + + + + {tab === "groups" ? ( + <> +
+ + + - ))} - - - {oidcConfig?.user_role_mapping && - Object.entries(oidcConfig.group_mapping) - .sort() - .map(([idpGroup, group]) => ( - - ))} - + +
+ + + + + + + {groupSyncSettings?.mapping && + Object.entries(groupSyncSettings.mapping) + .sort() + .map(([idpGroup, groups]) => ( + + ))} + + + + ) : ( + <> +
+ +
+ + + + + + {roleSyncSettings?.mapping && + Object.entries(roleSyncSettings.mapping) + .sort() + .map(([idpRole, roles]) => ( + + ))} + + + )}
@@ -125,37 +187,66 @@ export const IdpSyncPageView: FC = ({ oidcConfig }) => { interface IdpFieldProps { name: string; fieldText: string | undefined; - showStatusIndicator?: boolean; + showDisabled?: boolean; } const IdpField: FC = ({ name, fieldText, - showStatusIndicator = false, + showDisabled = false, }) => { return ( - -

{name}

-

- {fieldText || - (showStatusIndicator && ( -

- -

disabled

-
- ))} -

+ +

{name}

+ {fieldText ? ( +

{fieldText}

+ ) : ( + showDisabled && ( +
+ +

disabled

+
+ ) + )}
); }; +interface TableRowCountProps { + count: number; + type: string; +} + +const TableRowCount: FC = ({ count, type }) => { + return ( +
({ + margin: 0, + fontSize: 13, + color: theme.palette.text.secondary, + "& strong": { + color: theme.palette.text.primary, + }, + })} + > + Showing {count} {type} +
+ ); +}; + interface IdpMappingTableProps { type: "Role" | "Group"; isEmpty: boolean; @@ -194,7 +285,9 @@ const IdpMappingTable: FC = ({