Skip to content

Commit a3ebcd7

Browse files
authored
feat: integrate backend with idp sync page (coder#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
1 parent b4f54f3 commit a3ebcd7

File tree

12 files changed

+654
-207
lines changed

12 files changed

+654
-207
lines changed

site/src/api/api.ts

+24
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,30 @@ class ApiMethods {
704704
return response.data;
705705
};
706706

707+
/**
708+
* @param organization Can be the organization's ID or name
709+
*/
710+
getGroupIdpSyncSettingsByOrganization = async (
711+
organization: string,
712+
): Promise<TypesGen.GroupSyncSettings> => {
713+
const response = await this.axios.get<TypesGen.GroupSyncSettings>(
714+
`/api/v2/organizations/${organization}/settings/idpsync/groups`,
715+
);
716+
return response.data;
717+
};
718+
719+
/**
720+
* @param organization Can be the organization's ID or name
721+
*/
722+
getRoleIdpSyncSettingsByOrganization = async (
723+
organization: string,
724+
): Promise<TypesGen.RoleSyncSettings> => {
725+
const response = await this.axios.get<TypesGen.RoleSyncSettings>(
726+
`/api/v2/organizations/${organization}/settings/idpsync/roles`,
727+
);
728+
return response.data;
729+
};
730+
707731
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
708732
const response = await this.axios.get<TypesGen.Template>(
709733
`/api/v2/templates/${templateId}`,

site/src/api/queries/organizations.ts

+33
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,32 @@ export const provisionerDaemonGroups = (organization: string) => {
141141
};
142142
};
143143

144+
export const getGroupIdpSyncSettingsKey = (organization: string) => [
145+
"organizations",
146+
organization,
147+
"groupIdpSyncSettings",
148+
];
149+
150+
export const groupIdpSyncSettings = (organization: string) => {
151+
return {
152+
queryKey: getGroupIdpSyncSettingsKey(organization),
153+
queryFn: () => API.getGroupIdpSyncSettingsByOrganization(organization),
154+
};
155+
};
156+
157+
export const getRoleIdpSyncSettingsKey = (organization: string) => [
158+
"organizations",
159+
organization,
160+
"roleIdpSyncSettings",
161+
];
162+
163+
export const roleIdpSyncSettings = (organization: string) => {
164+
return {
165+
queryKey: getRoleIdpSyncSettingsKey(organization),
166+
queryFn: () => API.getRoleIdpSyncSettingsByOrganization(organization),
167+
};
168+
};
169+
144170
/**
145171
* Fetch permissions for a single organization.
146172
*
@@ -243,6 +269,13 @@ export const organizationsPermissions = (
243269
},
244270
action: "read",
245271
},
272+
viewIdpSyncSettings: {
273+
object: {
274+
resource_type: "idpsync_settings",
275+
organization_id: organizationId,
276+
},
277+
action: "read",
278+
},
246279
});
247280

248281
// The endpoint takes a flat array, so to avoid collisions prepend each

site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const AppearanceSettingsPageView: FC<
7474
<PopoverContent css={{ transform: "translateY(-28px)" }}>
7575
<PopoverPaywall
7676
message="Appearance"
77-
description="With a Premium license, you can customize the appearance of your deployment."
77+
description="With a Premium license, you can customize the appearance and branding of your deployment."
7878
documentationLink="https://coder.com/docs/admin/appearance"
7979
/>
8080
</PopoverContent>

site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ import { Link as RouterLink, useNavigate } from "react-router-dom";
2929
import { docs } from "utils/docs";
3030
import { PermissionPillsList } from "./PermissionPillsList";
3131

32-
export type CustomRolesPageViewProps = {
32+
interface CustomRolesPageViewProps {
3333
roles: Role[] | undefined;
3434
onDeleteRole: (role: Role) => void;
3535
canAssignOrgRole: boolean;
3636
isCustomRolesEnabled: boolean;
37-
};
37+
}
3838

3939
export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
4040
roles,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
3+
import {
4+
MockGroupSyncSettings,
5+
MockOrganization,
6+
MockRoleSyncSettings,
7+
} from "testHelpers/entities";
8+
import { ExportPolicyButton } from "./ExportPolicyButton";
9+
10+
const meta: Meta<typeof ExportPolicyButton> = {
11+
title: "modules/resources/ExportPolicyButton",
12+
component: ExportPolicyButton,
13+
args: {
14+
syncSettings: MockGroupSyncSettings,
15+
type: "groups",
16+
organization: MockOrganization,
17+
},
18+
};
19+
20+
export default meta;
21+
type Story = StoryObj<typeof ExportPolicyButton>;
22+
23+
export const Default: Story = {};
24+
25+
export const ClickExportGroupPolicy: Story = {
26+
args: {
27+
syncSettings: MockGroupSyncSettings,
28+
type: "groups",
29+
organization: MockOrganization,
30+
download: fn(),
31+
},
32+
play: async ({ canvasElement, args }) => {
33+
const canvas = within(canvasElement);
34+
await userEvent.click(
35+
canvas.getByRole("button", { name: "Export Policy" }),
36+
);
37+
await waitFor(() =>
38+
expect(args.download).toHaveBeenCalledWith(
39+
expect.anything(),
40+
`${MockOrganization.name}_groups-policy.json`,
41+
),
42+
);
43+
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
44+
await expect(blob.type).toEqual("application/json");
45+
await expect(await blob.text()).toEqual(
46+
JSON.stringify(MockGroupSyncSettings, null, 2),
47+
);
48+
},
49+
};
50+
51+
export const ClickExportRolePolicy: Story = {
52+
args: {
53+
syncSettings: MockRoleSyncSettings,
54+
type: "roles",
55+
organization: MockOrganization,
56+
download: fn(),
57+
},
58+
play: async ({ canvasElement, args }) => {
59+
const canvas = within(canvasElement);
60+
await userEvent.click(
61+
canvas.getByRole("button", { name: "Export Policy" }),
62+
);
63+
await waitFor(() =>
64+
expect(args.download).toHaveBeenCalledWith(
65+
expect.anything(),
66+
`${MockOrganization.name}_roles-policy.json`,
67+
),
68+
);
69+
const blob: Blob = (args.download as jest.Mock).mock.lastCall[0];
70+
await expect(blob.type).toEqual("application/json");
71+
await expect(await blob.text()).toEqual(
72+
JSON.stringify(MockRoleSyncSettings, null, 2),
73+
);
74+
},
75+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
2+
import Button from "@mui/material/Button";
3+
import type {
4+
GroupSyncSettings,
5+
Organization,
6+
RoleSyncSettings,
7+
} from "api/typesGenerated";
8+
import { displayError } from "components/GlobalSnackbar/utils";
9+
import { saveAs } from "file-saver";
10+
import { type FC, useMemo, useState } from "react";
11+
12+
interface DownloadPolicyButtonProps {
13+
syncSettings: RoleSyncSettings | GroupSyncSettings | undefined;
14+
type: "groups" | "roles";
15+
organization: Organization;
16+
download?: (file: Blob, filename: string) => void;
17+
}
18+
19+
export const ExportPolicyButton: FC<DownloadPolicyButtonProps> = ({
20+
syncSettings,
21+
type,
22+
organization,
23+
download = saveAs,
24+
}) => {
25+
const [isDownloading, setIsDownloading] = useState(false);
26+
27+
const policyJSON = useMemo(() => {
28+
return syncSettings?.field && syncSettings.mapping
29+
? JSON.stringify(syncSettings, null, 2)
30+
: null;
31+
}, [syncSettings]);
32+
33+
return (
34+
<Button
35+
startIcon={<DownloadOutlined />}
36+
disabled={!policyJSON || isDownloading}
37+
onClick={async () => {
38+
if (policyJSON) {
39+
try {
40+
setIsDownloading(true);
41+
const file = new Blob([policyJSON], {
42+
type: "application/json",
43+
});
44+
download(file, `${organization.name}_${type}-policy.json`);
45+
} catch (e) {
46+
console.error(e);
47+
displayError("Failed to export policy json");
48+
} finally {
49+
setIsDownloading(false);
50+
}
51+
}
52+
}}
53+
>
54+
Export Policy
55+
</Button>
56+
);
57+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
2+
import Stack from "@mui/material/Stack";
3+
import { Pill } from "components/Pill/Pill";
4+
import {
5+
Popover,
6+
PopoverContent,
7+
PopoverTrigger,
8+
} from "components/Popover/Popover";
9+
import type { FC } from "react";
10+
11+
interface PillListProps {
12+
roles: readonly string[];
13+
}
14+
15+
// used to check if the role is a UUID
16+
const UUID =
17+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18+
19+
export const IdpPillList: FC<PillListProps> = ({ roles }) => {
20+
return (
21+
<Stack direction="row" spacing={1}>
22+
{roles.length > 0 ? (
23+
<Pill css={UUID.test(roles[0]) ? styles.errorPill : styles.pill}>
24+
{roles[0]}
25+
</Pill>
26+
) : (
27+
<p>None</p>
28+
)}
29+
30+
{roles.length > 1 && <OverflowPill roles={roles.slice(1)} />}
31+
</Stack>
32+
);
33+
};
34+
35+
interface OverflowPillProps {
36+
roles: string[];
37+
}
38+
39+
const OverflowPill: FC<OverflowPillProps> = ({ roles }) => {
40+
const theme = useTheme();
41+
42+
return (
43+
<Popover mode="hover">
44+
<PopoverTrigger>
45+
<Pill
46+
css={{
47+
backgroundColor: theme.palette.background.paper,
48+
borderColor: theme.palette.divider,
49+
}}
50+
data-testid="overflow-pill"
51+
>
52+
+{roles.length} more
53+
</Pill>
54+
</PopoverTrigger>
55+
56+
<PopoverContent
57+
disableRestoreFocus
58+
disableScrollLock
59+
css={{
60+
".MuiPaper-root": {
61+
display: "flex",
62+
flexFlow: "column wrap",
63+
columnGap: 8,
64+
rowGap: 12,
65+
padding: "12px 16px",
66+
alignContent: "space-around",
67+
minWidth: "auto",
68+
backgroundColor: theme.palette.background.default,
69+
},
70+
}}
71+
anchorOrigin={{
72+
vertical: -4,
73+
horizontal: "center",
74+
}}
75+
transformOrigin={{
76+
vertical: "bottom",
77+
horizontal: "center",
78+
}}
79+
>
80+
{roles.map((role) => (
81+
<Pill
82+
key={role}
83+
css={UUID.test(role) ? styles.errorPill : styles.pill}
84+
>
85+
{role}
86+
</Pill>
87+
))}
88+
</PopoverContent>
89+
</Popover>
90+
);
91+
};
92+
93+
const styles = {
94+
pill: (theme) => ({
95+
backgroundColor: theme.experimental.pillDefault.background,
96+
borderColor: theme.experimental.pillDefault.outline,
97+
color: theme.experimental.pillDefault.text,
98+
width: "fit-content",
99+
}),
100+
errorPill: (theme) => ({
101+
backgroundColor: theme.roles.error.background,
102+
borderColor: theme.roles.error.outline,
103+
color: theme.roles.error.text,
104+
width: "fit-content",
105+
}),
106+
} satisfies Record<string, Interpolation<Theme>>;

0 commit comments

Comments
 (0)