Skip to content

Commit a3ebcd7

Browse files
authored
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
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

Lines changed: 24 additions & 0 deletions
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

Lines changed: 33 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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,
Lines changed: 75 additions & 0 deletions
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+
};
Lines changed: 57 additions & 0 deletions
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+
};
Lines changed: 106 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy