feat: enable editing of IDP sync configuration for groups and roles in the UI (#16098)

contributes to #15290 

The goal of this PR is to port the work to implement CRUD in the UI for
IDP organization sync settings and apply this to group and role IDP sync
settings.

<img width="1143" alt="Screenshot 2025-01-16 at 20 25 21"
src="https://github.com/user-attachments/assets/c5d09291-e98c-497c-8c23-a3cdcdccb90d"
/>
<img width="1142" alt="Screenshot 2025-01-16 at 20 25 39"
src="https://github.com/user-attachments/assets/1f569e1f-1474-49fa-8c80-aa8cf0d0e4db"
/>
This commit is contained in:
Jaayden Halko
2025-01-27 18:31:48 +00:00
committed by GitHub
parent 75c899ff71
commit f5186699ad
24 changed files with 1404 additions and 587 deletions

View File

@ -81,13 +81,49 @@ export const createOrganizationSyncSettings = async () => {
"fbd2116a-8961-4954-87ae-e4575bd29ce0",
"13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
],
"idp-org-2": ["fbd2116a-8961-4954-87ae-e4575bd29ce0"],
"idp-org-2": ["6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b"],
},
organization_assign_default: true,
});
return settings;
};
export const createGroupSyncSettings = async (orgId: string) => {
const settings = await API.patchGroupIdpSyncSettings(
{
field: "group-field-test",
mapping: {
"idp-group-1": [
"fbd2116a-8961-4954-87ae-e4575bd29ce0",
"13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
],
"idp-group-2": ["6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b"],
},
regex_filter: "@[a-zA-Z0-9]+",
auto_create_missing_groups: true,
},
orgId,
);
return settings;
};
export const createRoleSyncSettings = async (orgId: string) => {
const settings = await API.patchRoleIdpSyncSettings(
{
field: "role-field-test",
mapping: {
"idp-role-1": [
"fbd2116a-8961-4954-87ae-e4575bd29ce0",
"13de3eb4-9b4f-49e7-b0f8-0c3728a0d2e2",
],
"idp-role-2": ["6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b"],
},
},
orgId,
);
return settings;
};
export const createCustomRole = async (
orgId: string,
name: string,

View File

@ -16,6 +16,22 @@ test.beforeEach(async ({ page }) => {
});
test.describe("IdpOrgSyncPage", () => {
test("show empty table when no org mappings are present", async ({
page,
}) => {
requiresLicense();
await page.goto("/deployment/idp-org-sync", {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("row", { name: "idp-org-1" }),
).not.toBeVisible();
await expect(
page.getByRole("heading", { name: "No organization mappings" }),
).toBeVisible();
});
test("add new IdP organization mapping with API", async ({ page }) => {
requiresLicense();
@ -29,14 +45,14 @@ test.describe("IdpOrgSyncPage", () => {
page.getByRole("switch", { name: "Assign Default Organization" }),
).toBeChecked();
await expect(page.getByText("idp-org-1")).toBeVisible();
await expect(page.getByRole("row", { name: "idp-org-1" })).toBeVisible();
await expect(
page.getByText("fbd2116a-8961-4954-87ae-e4575bd29ce0").first(),
page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }),
).toBeVisible();
await expect(page.getByText("idp-org-2")).toBeVisible();
await expect(page.getByRole("row", { name: "idp-org-2" })).toBeVisible();
await expect(
page.getByText("fbd2116a-8961-4954-87ae-e4575bd29ce0").last(),
page.getByRole("row", { name: "6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b" }),
).toBeVisible();
});
@ -47,12 +63,12 @@ test.describe("IdpOrgSyncPage", () => {
waitUntil: "domcontentloaded",
});
await expect(page.getByText("idp-org-1")).toBeVisible();
await page
.getByRole("button", { name: /delete/i })
.first()
.click();
await expect(page.getByText("idp-org-1")).not.toBeVisible();
const row = page.getByTestId("idp-org-idp-org-1");
await expect(row.getByRole("cell", { name: "idp-org-1" })).toBeVisible();
await row.getByRole("button", { name: /delete/i }).click();
await expect(
row.getByRole("cell", { name: "idp-org-1" }),
).not.toBeVisible();
await expect(
page.getByText("Organization sync settings updated."),
).toBeVisible();
@ -67,7 +83,7 @@ test.describe("IdpOrgSyncPage", () => {
const syncField = page.getByRole("textbox", {
name: "Organization sync field",
});
const saveButton = page.getByRole("button", { name: /save/i }).first();
const saveButton = page.getByRole("button", { name: /save/i });
await expect(saveButton).toBeDisabled();
@ -154,8 +170,10 @@ test.describe("IdpOrgSyncPage", () => {
// Verify new mapping appears in table
const newRow = page.getByTestId("idp-org-new-idp-org");
await expect(newRow).toBeVisible();
await expect(newRow.getByText("new-idp-org")).toBeVisible();
await expect(newRow.getByText(orgName)).toBeVisible();
await expect(
newRow.getByRole("cell", { name: "new-idp-org" }),
).toBeVisible();
await expect(newRow.getByRole("cell", { name: orgName })).toBeVisible();
await expect(
page.getByText("Organization sync settings updated."),

View File

@ -0,0 +1,184 @@
import { expect, test } from "@playwright/test";
import {
createGroupSyncSettings,
createOrganizationWithName,
deleteOrganization,
setupApiCalls,
} from "../../api";
import { randomName, requiresLicense } from "../../helpers";
import { login } from "../../helpers";
import { beforeCoderTest } from "../../hooks";
test.beforeEach(async ({ page }) => {
beforeCoderTest(page);
await login(page);
await setupApiCalls(page);
});
test.describe("IdpGroupSyncPage", () => {
test("show empty table when no group mappings are present", async ({
page,
}) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("row", { name: "idp-group-1" }),
).not.toBeVisible();
await expect(
page.getByRole("heading", { name: "No group mappings" }),
).toBeVisible();
await deleteOrganization(org.name);
});
test("add new IdP group mapping with API", async ({ page }) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await createGroupSyncSettings(org.id);
await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("switch", { name: "Auto create missing groups" }),
).toBeChecked();
await expect(page.getByRole("row", { name: "idp-group-1" })).toBeVisible();
await expect(
page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }),
).toBeVisible();
await expect(page.getByRole("row", { name: "idp-group-2" })).toBeVisible();
await expect(
page.getByRole("row", { name: "6b39f0f1-6ad8-4981-b2fc-d52aef53ff1b" }),
).toBeVisible();
await deleteOrganization(org.name);
});
test("delete a IdP group to coder group mapping row", async ({ page }) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await createGroupSyncSettings(org.id);
await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, {
waitUntil: "domcontentloaded",
});
const row = page.getByTestId("group-idp-group-1");
await expect(row.getByRole("cell", { name: "idp-group-1" })).toBeVisible();
await row.getByRole("button", { name: /delete/i }).click();
await expect(
row.getByRole("cell", { name: "idp-group-1" }),
).not.toBeVisible();
await expect(
page.getByText("IdP Group sync settings updated."),
).toBeVisible();
});
test("update sync field", async ({ page }) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, {
waitUntil: "domcontentloaded",
});
const syncField = page.getByRole("textbox", {
name: "Group sync field",
});
const saveButton = page.getByRole("button", { name: /save/i });
await expect(saveButton).toBeDisabled();
await syncField.fill("test-field");
await expect(saveButton).toBeEnabled();
await page.getByRole("button", { name: /save/i }).click();
await expect(
page.getByText("IdP Group sync settings updated."),
).toBeVisible();
});
test("toggle off auto create missing groups", async ({ page }) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, {
waitUntil: "domcontentloaded",
});
const toggle = page.getByRole("switch", {
name: "Auto create missing groups",
});
await toggle.click();
await expect(
page.getByText("IdP Group sync settings updated."),
).toBeVisible();
await expect(toggle).toBeChecked();
});
test("export policy button is enabled when sync settings are present", async ({
page,
}) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await createGroupSyncSettings(org.id);
await page.goto(`/organizations/${org.name}/idp-sync?tab=groups`, {
waitUntil: "domcontentloaded",
});
const exportButton = page.getByRole("button", { name: /Export Policy/i });
await expect(exportButton).toBeEnabled();
await exportButton.click();
});
test("add new IdP group mapping with UI", async ({ page }) => {
requiresLicense();
const orgName = randomName();
await createOrganizationWithName(orgName);
await page.goto(`/organizations/${orgName}/idp-sync?tab=groups`, {
waitUntil: "domcontentloaded",
});
const idpOrgInput = page.getByLabel("IdP group name");
const orgSelector = page.getByPlaceholder("Select group");
const addButton = page.getByRole("button", {
name: /Add IdP group/i,
});
await expect(addButton).toBeDisabled();
await idpOrgInput.fill("new-idp-group");
// Select Coder organization from combobox
await orgSelector.click();
await page.getByRole("option", { name: /Everyone/i }).click();
// Add button should now be enabled
await expect(addButton).toBeEnabled();
await addButton.click();
// Verify new mapping appears in table
const newRow = page.getByTestId("group-new-idp-group");
await expect(newRow).toBeVisible();
await expect(
newRow.getByRole("cell", { name: "new-idp-group" }),
).toBeVisible();
await expect(newRow.getByRole("cell", { name: "Everyone" })).toBeVisible();
await expect(
page.getByText("IdP Group sync settings updated."),
).toBeVisible();
await deleteOrganization(orgName);
});
});

View File

@ -0,0 +1,167 @@
import { expect, test } from "@playwright/test";
import {
createOrganizationWithName,
createRoleSyncSettings,
deleteOrganization,
setupApiCalls,
} from "../../api";
import { randomName, requiresLicense } from "../../helpers";
import { login } from "../../helpers";
import { beforeCoderTest } from "../../hooks";
test.beforeEach(async ({ page }) => {
beforeCoderTest(page);
await login(page);
await setupApiCalls(page);
});
test.describe("IdpRoleSyncPage", () => {
test("show empty table when no role mappings are present", async ({
page,
}) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("row", { name: "idp-role-1" }),
).not.toBeVisible();
await expect(
page.getByRole("heading", { name: "No role mappings" }),
).toBeVisible();
await deleteOrganization(org.name);
});
test("add new IdP role mapping with API", async ({ page }) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await createRoleSyncSettings(org.id);
await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, {
waitUntil: "domcontentloaded",
});
await expect(page.getByRole("row", { name: "idp-role-1" })).toBeVisible();
await expect(
page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }),
).toBeVisible();
await expect(page.getByRole("row", { name: "idp-role-2" })).toBeVisible();
await expect(
page.getByRole("row", { name: "fbd2116a-8961-4954-87ae-e4575bd29ce0" }),
).toBeVisible();
await deleteOrganization(org.name);
});
test("delete a IdP role to coder role mapping row", async ({ page }) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await createRoleSyncSettings(org.id);
await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, {
waitUntil: "domcontentloaded",
});
const row = page.getByTestId("role-idp-role-1");
await expect(row.getByRole("cell", { name: "idp-role-1" })).toBeVisible();
await row.getByRole("button", { name: /delete/i }).click();
await expect(
row.getByRole("cell", { name: "idp-role-1" }),
).not.toBeVisible();
await expect(
page.getByText("IdP Role sync settings updated."),
).toBeVisible();
await deleteOrganization(org.name);
});
test("update sync field", async ({ page }) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, {
waitUntil: "domcontentloaded",
});
const syncField = page.getByRole("textbox", {
name: "Role sync field",
});
const saveButton = page.getByRole("button", { name: /save/i });
await expect(saveButton).toBeDisabled();
await syncField.fill("test-field");
await expect(saveButton).toBeEnabled();
await page.getByRole("button", { name: /save/i }).click();
await expect(
page.getByText("IdP Role sync settings updated."),
).toBeVisible();
await deleteOrganization(org.name);
});
test("export policy button is enabled when sync settings are present", async ({
page,
}) => {
requiresLicense();
const org = await createOrganizationWithName(randomName());
await page.goto(`/organizations/${org.name}/idp-sync?tab=roles`, {
waitUntil: "domcontentloaded",
});
const exportButton = page.getByRole("button", { name: /Export Policy/i });
await createRoleSyncSettings(org.id);
await expect(exportButton).toBeEnabled();
await exportButton.click();
});
test("add new IdP role mapping with UI", async ({ page }) => {
requiresLicense();
const orgName = randomName();
await createOrganizationWithName(orgName);
await page.goto(`/organizations/${orgName}/idp-sync?tab=roles`, {
waitUntil: "domcontentloaded",
});
const idpOrgInput = page.getByLabel("IdP role name");
const roleSelector = page.getByPlaceholder("Select role");
const addButton = page.getByRole("button", {
name: /Add IdP role/i,
});
await expect(addButton).toBeDisabled();
await idpOrgInput.fill("new-idp-role");
// Select Coder role from combobox
await roleSelector.click();
await page.getByRole("option", { name: /Organization Admin/i }).click();
// Add button should now be enabled
await expect(addButton).toBeEnabled();
await addButton.click();
// Verify new mapping appears in table
const newRow = page.getByTestId("role-new-idp-role");
await expect(newRow).toBeVisible();
await expect(
newRow.getByRole("cell", { name: "new-idp-role" }),
).toBeVisible();
await expect(
newRow.getByRole("cell", { name: "organization-admin" }),
).toBeVisible();
await expect(
page.getByText("IdP Role sync settings updated."),
).toBeVisible();
await deleteOrganization(orgName);
});
});

View File

@ -733,6 +733,36 @@ class ApiMethods {
return response.data;
};
/**
* @param data
* @param organization Can be the organization's ID or name
*/
patchGroupIdpSyncSettings = async (
data: TypesGen.GroupSyncSettings,
organization: string,
) => {
const response = await this.axios.patch<TypesGen.Response>(
`/api/v2/organizations/${organization}/settings/idpsync/groups`,
data,
);
return response.data;
};
/**
* @param data
* @param organization Can be the organization's ID or name
*/
patchRoleIdpSyncSettings = async (
data: TypesGen.RoleSyncSettings,
organization: string,
) => {
const response = await this.axios.patch<TypesGen.Response>(
`/api/v2/organizations/${organization}/settings/idpsync/roles`,
data,
);
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/

View File

@ -2,6 +2,8 @@ import { API } from "api/api";
import type {
AuthorizationResponse,
CreateOrganizationRequest,
GroupSyncSettings,
RoleSyncSettings,
UpdateOrganizationRequest,
} from "api/typesGenerated";
import type { QueryClient } from "react-query";
@ -156,6 +158,18 @@ export const groupIdpSyncSettings = (organization: string) => {
};
};
export const patchGroupSyncSettings = (
organization: string,
queryClient: QueryClient,
) => {
return {
mutationFn: (request: GroupSyncSettings) =>
API.patchGroupIdpSyncSettings(request, organization),
onSuccess: async () =>
await queryClient.invalidateQueries(groupIdpSyncSettings(organization)),
};
};
export const getRoleIdpSyncSettingsKey = (organization: string) => [
"organizations",
organization,
@ -169,6 +183,20 @@ export const roleIdpSyncSettings = (organization: string) => {
};
};
export const patchRoleSyncSettings = (
organization: string,
queryClient: QueryClient,
) => {
return {
mutationFn: (request: RoleSyncSettings) =>
API.patchRoleIdpSyncSettings(request, organization),
onSuccess: async () =>
await queryClient.invalidateQueries(
getRoleIdpSyncSettingsKey(organization),
),
};
};
/**
* Fetch permissions for a single organization.
*

View File

@ -10,10 +10,10 @@ import { cn } from "utils/cn";
export const buttonVariants = cva(
`inline-flex items-center justify-center gap-1 whitespace-nowrap
border-solid rounded-md transition-colors min-w-20
text-sm font-semibold font-medium cursor-pointer no-underline
text-sm font-semibold font-medium cursor-pointer no-underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-[2px]`,
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5`,
{
variants: {
variant: {
@ -30,6 +30,7 @@ export const buttonVariants = cva(
size: {
lg: "h-10 px-3 py-2 [&_svg]:size-icon-lg",
sm: "h-[30px] px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
icon: "h-[30px] min-w-[30px] px-1 py-1.5 [&_svg]:size-icon-sm",
},
},
defaultVariants: {

View File

@ -13,7 +13,7 @@ export const Input = forwardRef<
<input
type={type}
className={cn(
`flex h-9 w-full rounded-md border border-border border-solid bg-transparent px-3 py-1
`flex h-10 w-full rounded-md border border-border border-solid bg-transparent px-3
text-base shadow-sm transition-colors
file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-content-primary
placeholder:text-content-secondary

View File

@ -6,15 +6,15 @@ import { cn } from "utils/cn";
export const linkVariants = cva(
`relative inline-flex items-center no-underline font-medium text-content-link hover:cursor-pointer
after:hover:content-[''] after:hover:absolute after:hover:left-0 after:hover:w-full after:hover:h-[1px] after:hover:bg-current after:hover:bottom-px
after:hover:content-[''] after:hover:absolute after:hover:left-0 after:hover:w-full after:hover:h-px after:hover:bg-current after:hover:bottom-px
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary focus-visible:rounded-sm
visited:text-content-link pl-[2px]`, //pl-[2px] adjusts the underline spacing to align with the icon on the right.
visited:text-content-link pl-0.5`, //pl-0.5 adjusts the underline spacing to align with the icon on the right.
{
variants: {
size: {
lg: "text-sm gap-[2px] [&_svg]:size-icon-sm [&_svg]:p-[2px] leading-6",
sm: "text-xs gap-1 [&_svg]:size-icon-xs [&_svg]:p-[1px] leading-[18px]",
lg: "text-sm gap-0.5 [&_svg]:size-icon-sm [&_svg]:p-0.5 leading-6",
sm: "text-xs gap-1 [&_svg]:size-icon-xs [&_svg]:p-px leading-5",
},
},
defaultVariants: {

View File

@ -430,6 +430,10 @@ export const MultiSelectCombobox = forwardRef<
return undefined;
};
if (inputRef.current && inputProps?.id) {
inputRef.current.id = inputProps?.id;
}
const fixedOptions = selected.filter((s) => s.fixed);
return (
@ -454,7 +458,7 @@ export const MultiSelectCombobox = forwardRef<
{/* biome-ignore lint/a11y/useKeyWithClickEvents: onKeyDown is not needed here */}
<div
className={cn(
`*:min-h-9 rounded-md border border-solid border-border text-sm pr-3
`min-h-10 rounded-md border border-solid border-border text-sm pr-3
focus-within:ring-2 focus-within:ring-content-link`,
{
"pl-3 py-1": selected.length !== 0,
@ -568,7 +572,7 @@ export const MultiSelectCombobox = forwardRef<
>
<X className="h-5 w-5" />
</button>
<ChevronDown className="h-6 w-6 cursor-pointer text-content-secondary hover:text-content-primary" />
<ChevronDown className="h-5 w-5 cursor-pointer text-content-secondary hover:text-content-primary" />
</div>
</div>
</div>

View File

@ -1,8 +1,8 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import { type FC, type HTMLAttributes, createContext, useContext } from "react";
import { Link, type LinkProps } from "react-router-dom";
import { cn } from "utils/cn";
export const TAB_PADDING_Y = 12;
// Keeping this for now because of a workaround in WorkspaceBUildPageView
export const TAB_PADDING_X = 16;
type TabsContextValue = {
@ -13,15 +13,16 @@ const TabsContext = createContext<TabsContextValue | undefined>(undefined);
type TabsProps = HTMLAttributes<HTMLDivElement> & TabsContextValue;
export const Tabs: FC<TabsProps> = ({ active, ...htmlProps }) => {
const theme = useTheme();
export const Tabs: FC<TabsProps> = ({ className, active, ...htmlProps }) => {
return (
<TabsContext.Provider value={{ active }}>
<div
css={{
borderBottom: `1px solid ${theme.palette.divider}`,
}}
// Because the Tailwind preflight is not used, its necessary to set border style to solid and
// reset all border widths to 0 https://tailwindcss.com/docs/border-width#using-without-preflight
className={cn(
"border-0 border-b border-solid border-border",
className,
)}
{...htmlProps}
/>
</TabsContext.Provider>
@ -31,16 +32,7 @@ export const Tabs: FC<TabsProps> = ({ active, ...htmlProps }) => {
type TabsListProps = HTMLAttributes<HTMLDivElement>;
export const TabsList: FC<TabsListProps> = (props) => {
return (
<div
role="tablist"
css={{
display: "flex",
alignItems: "baseline",
}}
{...props}
/>
);
return <div role="tablist" className="flex items-baseline" {...props} />;
};
type TabLinkProps = LinkProps & {
@ -59,37 +51,15 @@ export const TabLink: FC<TabLinkProps> = ({ value, ...linkProps }) => {
return (
<Link
{...linkProps}
css={[styles.tabLink, isActive ? styles.activeTabLink : ""]}
className={cn(
`text-sm text-content-secondary no-underline font-medium py-3 px-1 mr-6 hover:text-content-primary rounded-md
focus-visible:ring-offset-1 focus-visible:ring-offset-surface-primary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link focus-visible:rounded-sm`,
{
"text-content-primary relative before:absolute before:bg-surface-invert-primary before:left-0 before:w-full before:h-px before:-bottom-px before:content-['']":
isActive,
},
)}
/>
);
};
const styles = {
tabLink: (theme) => ({
textDecoration: "none",
color: theme.palette.text.secondary,
fontSize: 14,
display: "block",
padding: `${TAB_PADDING_Y}px ${TAB_PADDING_X}px`,
fontWeight: 500,
lineHeight: "1",
"&:hover": {
color: theme.palette.text.primary,
},
}),
activeTabLink: (theme) => ({
color: theme.palette.text.primary,
position: "relative",
"&:before": {
content: '""',
left: 0,
bottom: -1,
height: 1,
width: "100%",
background: theme.palette.primary.main,
position: "absolute",
},
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -6,9 +6,9 @@ import {
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { displayError } from "components/GlobalSnackbar/utils";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Link } from "components/Link/Link";
import { Loader } from "components/Loader/Loader";
import { Paywall } from "components/Paywall/Paywall";
import { SquareArrowOutUpRight } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { type FC, useEffect } from "react";
@ -62,13 +62,9 @@ export const IdpOrgSyncPage: FC = () => {
<p className="flex flex-row gap-1 text-sm text-content-secondary font-medium m-0">
Automatically assign users to an organization based on their IdP
claims.
<a
href={docs("/admin/users/idp-sync")}
className="flex flex-row text-content-link items-center gap-1 no-underline hover:underline visited:text-content-link"
>
<Link href={docs("/admin/users/idp-sync#organization-sync")}>
View docs
<SquareArrowOutUpRight size={14} />
</a>
</Link>
</p>
</div>
<ExportPolicyButton syncSettings={orgSyncSettingsData} />

View File

@ -28,15 +28,18 @@ import {
} from "components/HelpTooltip/HelpTooltip";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import {
MultiSelectCombobox,
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import { useFormik } from "formik";
import { Plus, SquareArrowOutUpRight, Trash } from "lucide-react";
import { type FC, useState } from "react";
import { Plus, Trash } from "lucide-react";
import { type FC, useId, useState } from "react";
import { docs } from "utils/docs";
import { isUUID } from "utils/uuid";
import * as Yup from "yup";
import { OrganizationPills } from "./OrganizationPills";
@ -50,9 +53,23 @@ interface IdpSyncPageViewProps {
const validationSchema = Yup.object({
field: Yup.string().trim(),
organization_assign_default: Yup.boolean(),
mapping: Yup.object().shape({
[`${String}`]: Yup.array().of(Yup.string()),
}),
mapping: Yup.object()
.test(
"valid-mapping",
"Invalid organization sync settings mapping structure",
(value) => {
if (!value) return true;
return Object.entries(value).every(
([key, arr]) =>
typeof key === "string" &&
Array.isArray(arr) &&
arr.every((item) => {
return typeof item === "string" && isUUID(item);
}),
);
},
)
.default({}),
});
export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
@ -78,6 +95,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
? Object.entries(form.values.mapping).length
: 0;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const id = useId();
const getOrgNames = (orgIds: readonly string[]) => {
return orgIds.map(
@ -100,10 +118,6 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
form.handleSubmit();
};
const SYNC_FIELD_ID = "sync-field";
const ORGANIZATION_ASSIGN_DEFAULT_ID = "organization-assign-default";
const IDP_ORGANIZATION_NAME_ID = "idp-organization-name";
return (
<div className="flex flex-col gap-2">
{Boolean(error) && <ErrorAlert error={error} />}
@ -111,15 +125,15 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
<fieldset disabled={form.isSubmitting} className="border-none">
<div className="flex flex-row">
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={SYNC_FIELD_ID}>
<Label className="text-sm" htmlFor={`${id}-sync-field`}>
Organization sync field
</Label>
<div className="flex flex-row items-center gap-5">
<div className="flex flex-row gap-2 w-72">
<Input
id={SYNC_FIELD_ID}
id={`${id}-sync-field`}
value={form.values.field}
onChange={async (event) => {
onChange={(event) => {
void form.setFieldValue("field", event.target.value);
}}
/>
@ -132,14 +146,15 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
form.handleSubmit();
}}
>
<Spinner loading={form.isSubmitting} />
Save
</Button>
</div>
<div className="flex flex-row items-center gap-3">
<Switch
id={ORGANIZATION_ASSIGN_DEFAULT_ID}
id={`${id}-assign-default-org`}
checked={form.values.organization_assign_default}
onCheckedChange={async (checked) => {
onCheckedChange={(checked) => {
if (!checked) {
setIsDialogOpen(true);
} else {
@ -152,7 +167,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
}}
/>
<span className="flex flex-row items-center gap-1">
<Label htmlFor={ORGANIZATION_ASSIGN_DEFAULT_ID}>
<Label htmlFor={`${id}-assign-default-org`}>
Assign Default Organization
</Label>
<AssignDefaultOrgHelpTooltip />
@ -164,15 +179,19 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
</p>
</div>
</div>
{form.errors.field && (
<p className="text-content-danger text-sm m-0">
{form.errors.field}
</p>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-row pt-8 gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={IDP_ORGANIZATION_NAME_ID}>
<Label className="text-sm" htmlFor={`${id}-idp-org-name`}>
IdP organization name
</Label>
<Input
id={IDP_ORGANIZATION_NAME_ID}
id={`${id}-idp-org-name`}
value={idpOrgName}
className="min-w-72 w-72"
onChange={(event) => {
@ -181,10 +200,13 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
/>
</div>
<div className="grid items-center gap-1 flex-1">
<Label className="text-sm" htmlFor=":r1d:">
<Label className="text-sm" htmlFor={`${id}-coder-org`}>
Coder organization
</Label>
<MultiSelectCombobox
inputProps={{
id: `${id}-coder-org`,
}}
className="min-w-60 max-w-3xl"
value={coderOrgs}
onChange={setCoderOrgs}
@ -201,11 +223,11 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
}
/>
</div>
<div className="grid items-center gap-1">
&nbsp;
<div className="grid grid-rows-[28px_auto]">
<div />
<Button
className="mb-px"
type="submit"
className="min-w-fit"
disabled={!idpOrgName || coderOrgs.length === 0}
onClick={async () => {
const newSyncSettings = {
@ -221,15 +243,24 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
setCoderOrgs([]);
}}
>
<Plus size={14} />
<Spinner loading={form.isSubmitting}>
<Plus size={14} />
</Spinner>
Add IdP organization
</Button>
</div>
</div>
{form.errors.mapping && (
<p className="text-content-danger text-sm m-0">
{Object.values(form.errors.mapping || {})}
</p>
)}
<IdpMappingTable isEmpty={organizationMappingCount === 0}>
{form.values.mapping &&
Object.entries(form.values.mapping)
.sort()
.sort(([a], [b]) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
)
.map(([idpOrg, organizations]) => (
<OrganizationRow
key={idpOrg}
@ -267,6 +298,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
}}
type="submit"
>
<Spinner loading={form.isSubmitting} />
Confirm
</Button>
</DialogFooter>
@ -301,15 +333,11 @@ const IdpMappingTable: FC<IdpMappingTableProps> = ({ isEmpty, children }) => {
message={"No organization mappings"}
isCompact
cta={
<Button variant="outline" asChild>
<a
href={docs("/admin/users/idp-sync")}
className="no-underline"
>
<SquareArrowOutUpRight size={14} />
How to set up IdP organization sync
</a>
</Button>
<Link
href={docs("/admin/users/idp-sync#organization-sync")}
>
How to set up IdP organization sync
</Link>
}
/>
</TableCell>
@ -344,7 +372,8 @@ const OrganizationRow: FC<OrganizationRowProps> = ({
<TableCell>
<Button
variant="outline"
className="w-8 h-8 px-1.5 py-1.5 text-content-secondary"
size="icon"
className="text-content-primary"
aria-label="delete"
onClick={() => onDelete(idpOrg)}
>

View File

@ -45,7 +45,6 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
canAssignOrgRole,
isCustomRolesEnabled,
}) => {
const theme = useTheme();
return (
<Stack spacing={4}>
{!isCustomRolesEnabled && (

View File

@ -32,7 +32,7 @@ export const ClickExportGroupPolicy: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Export Policy" }),
canvas.getByRole("button", { name: "Export policy" }),
);
await waitFor(() =>
expect(args.download).toHaveBeenCalledWith(
@ -58,7 +58,7 @@ export const ClickExportRolePolicy: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Export Policy" }),
canvas.getByRole("button", { name: "Export policy" }),
);
await waitFor(() =>
expect(args.download).toHaveBeenCalledWith(

View File

@ -29,6 +29,8 @@ export const ExportPolicyButton: FC<DownloadPolicyButtonProps> = ({
return (
<Button
size="sm"
variant="outline"
disabled={!canCreatePolicyJson || isDownloading}
onClick={async () => {
if (canCreatePolicyJson) {
@ -48,7 +50,7 @@ export const ExportPolicyButton: FC<DownloadPolicyButtonProps> = ({
}}
>
<DownloadIcon />
Export Policy
Export policy
</Button>
);
};

View File

@ -0,0 +1,371 @@
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import type {
Group,
GroupSyncSettings,
Organization,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import {
MultiSelectCombobox,
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import { useFormik } from "formik";
import { Plus, Trash } from "lucide-react";
import { type FC, useId, useState } from "react";
import { docs } from "utils/docs";
import { isUUID } from "utils/uuid";
import * as Yup from "yup";
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(),
auto_create_missing_groups: Yup.boolean(),
mapping: Yup.object()
.test(
"valid-mapping",
"Invalid group sync settings mapping structure",
(value) => {
if (!value) return true;
return Object.entries(value).every(
([key, arr]) =>
typeof key === "string" &&
Array.isArray(arr) &&
arr.every((item) => {
return typeof item === "string" && isUUID(item);
}),
);
},
)
.default({}),
});
export const IdpGroupSyncForm = ({
groupSyncSettings,
groupMappingCount,
legacyGroupMappingCount,
groups,
groupsMap,
organization,
onSubmit,
}: IdpGroupSyncFormProps) => {
const form = useFormik<GroupSyncSettings>({
initialValues: {
field: groupSyncSettings?.field ?? "",
regex_filter: groupSyncSettings?.regex_filter ?? "",
auto_create_missing_groups:
groupSyncSettings?.auto_create_missing_groups ?? false,
mapping: groupSyncSettings?.mapping ?? {},
},
validationSchema: groupSyncValidationSchema,
onSubmit,
enableReinitialize: Boolean(groupSyncSettings),
});
const [idpGroupName, setIdpGroupName] = useState("");
const [coderGroups, setCoderGroups] = useState<Option[]>([]);
const id = useId();
const getGroupNames = (groupIds: readonly string[]) => {
return groupIds.map((groupId) => groupsMap.get(groupId) || groupId);
};
const handleDelete = async (idpOrg: string) => {
const newMapping = Object.fromEntries(
Object.entries(form.values.mapping || {}).filter(
([key]) => key !== idpOrg,
),
);
const newSyncSettings = {
...form.values,
mapping: newMapping,
};
void form.setFieldValue("mapping", newSyncSettings.mapping);
form.handleSubmit();
};
return (
<form onSubmit={form.handleSubmit}>
<fieldset
disabled={form.isSubmitting}
className="flex flex-col border-none gap-8 pt-2"
>
<div className="flex justify-end">
<ExportPolicyButton
syncSettings={groupSyncSettings}
organization={organization}
type="groups"
/>
</div>
<div className="grid items-center gap-3">
<div className="flex flex-row items-center gap-5">
<div className="grid grid-cols-2 gap-2 grid-rows-[20px_auto_20px]">
<Label className="text-sm" htmlFor={`${id}-sync-field`}>
Group sync field
</Label>
<Label className="text-sm" htmlFor={`${id}-regex-filter`}>
Regex filter
</Label>
<Input
id={`${id}-sync-field`}
value={form.values.field}
onChange={(event) => {
void form.setFieldValue("field", event.target.value);
}}
className="w-72"
/>
<div className="flex flex-row gap-2">
<Input
id={`${id}-regex-filter`}
value={form.values.regex_filter ?? ""}
onChange={(event) => {
void form.setFieldValue("regex_filter", event.target.value);
}}
className="min-w-40"
/>
<Button
type="submit"
disabled={form.isSubmitting || !form.dirty}
onClick={(event) => {
event.preventDefault();
form.handleSubmit();
}}
>
<Spinner loading={form.isSubmitting} />
Save
</Button>
</div>
<p className="text-content-secondary text-2xs m-0">
If empty, group sync is deactivated
</p>
</div>
</div>
{form.errors.field ||
(form.errors.regex_filter && (
<p className="text-content-danger text-sm m-0">
{form.errors.field || form.errors.regex_filter}
</p>
))}
</div>
<div className="flex flex-row items-center gap-3">
<Spinner size="sm" loading={form.isSubmitting} className="w-9">
<Switch
id={`${id}-auto-create-missing-groups`}
checked={form.values.auto_create_missing_groups}
onCheckedChange={(checked) => {
void form.setFieldValue("auto_create_missing_groups", checked);
form.handleSubmit();
}}
/>
</Spinner>
<span className="flex flex-row items-center gap-1">
<Label htmlFor={`${id}-auto-create-missing-groups`}>
Auto create missing groups
</Label>
<AutoCreateMissingGroupsHelpTooltip />
</span>
</div>
<div className="flex flex-row gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={`${id}-idp-group-name`}>
IdP group name
</Label>
<Input
id={`${id}-idp-group-name`}
value={idpGroupName}
className="w-72"
onChange={(event) => {
setIdpGroupName(event.target.value);
}}
/>
</div>
<div className="grid items-center gap-1 flex-1">
<Label className="text-sm" htmlFor={`${id}-coder-group`}>
Coder group
</Label>
<MultiSelectCombobox
inputProps={{
id: `${id}-coder-group`,
}}
className="min-w-60 max-w-3xl"
value={coderGroups}
onChange={setCoderGroups}
defaultOptions={groups.map((group) => ({
label: group.display_name || group.name,
value: group.id,
}))}
hidePlaceholderWhenSelected
placeholder="Select group"
emptyIndicator={
<p className="text-center text-md text-content-primary">
All groups selected
</p>
}
/>
</div>
<div className="grid grid-rows-[28px_auto]">
<div />
<Button
type="submit"
className="min-w-fit"
disabled={!idpGroupName || coderGroups.length === 0}
onClick={() => {
const newSyncSettings = {
...form.values,
mapping: {
...form.values.mapping,
[idpGroupName]: coderGroups.map((group) => group.value),
},
};
void form.setFieldValue("mapping", newSyncSettings.mapping);
form.handleSubmit();
setIdpGroupName("");
setCoderGroups([]);
}}
>
<Spinner loading={form.isSubmitting}>
<Plus size={14} />
</Spinner>
Add IdP group
</Button>
</div>
</div>
{form.errors.mapping && (
<p className="text-content-danger text-sm m-0">
{Object.values(form.errors.mapping || {})}
</p>
)}
<div className="flex flex-col">
<IdpMappingTable type="Group" rowCount={groupMappingCount}>
{groupSyncSettings?.mapping &&
Object.entries(groupSyncSettings.mapping)
.sort(([a], [b]) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
)
.map(([idpGroup, groups]) => (
<GroupRow
key={idpGroup}
idpGroup={idpGroup}
coderGroup={getGroupNames(groups)}
onDelete={handleDelete}
/>
))}
</IdpMappingTable>
{groupSyncSettings?.legacy_group_name_mapping && (
<div>
<LegacyGroupSyncHeader />
<IdpMappingTable type="Group" rowCount={legacyGroupMappingCount}>
{Object.entries(groupSyncSettings.legacy_group_name_mapping)
.sort(([a], [b]) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
)
.map(([idpGroup, groupId]) => (
<GroupRow
key={groupId}
idpGroup={idpGroup}
coderGroup={getGroupNames([groupId])}
onDelete={handleDelete}
/>
))}
</IdpMappingTable>
</div>
)}
</div>
</fieldset>
</form>
);
};
interface GroupRowProps {
idpGroup: string;
coderGroup: readonly string[];
onDelete: (idpOrg: string) => void;
}
const GroupRow: FC<GroupRowProps> = ({ idpGroup, coderGroup, onDelete }) => {
return (
<TableRow data-testid={`group-${idpGroup}`}>
<TableCell>{idpGroup}</TableCell>
<TableCell>
<IdpPillList roles={coderGroup} />
</TableCell>
<TableCell>
<Button
variant="outline"
size="icon"
className="text-content-primary"
aria-label="delete"
onClick={() => onDelete(idpGroup)}
>
<Trash />
<span className="sr-only">Delete IdP mapping</span>
</Button>
</TableCell>
</TableRow>
);
};
const AutoCreateMissingGroupsHelpTooltip: FC = () => {
return (
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipText>
Enabling auto create missing groups will automatically create groups
returned by the OIDC provider if they do not exist in Coder.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
const LegacyGroupSyncHeader: FC = () => {
return (
<h4 className="text-xl font-medium">
<div className="flex items-end gap-2">
<span>Legacy group sync settings</span>
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipTitle>Legacy group sync settings</HelpTooltipTitle>
<HelpTooltipText>
These settings were configured using environment variables, and
only apply to the default organization. It is now recommended to
configure IdP sync via the CLI or the UI, which enables sync to be
configured for any organization, and for those settings to be
persisted without manually setting environment variables.{" "}
<Link href={docs("/admin/users/idp-sync")}>
Learn more&hellip;
</Link>
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
</div>
</h4>
);
};

View File

@ -0,0 +1,71 @@
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
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 { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Link } from "components/Link/Link";
import type { FC } from "react";
import { docs } from "utils/docs";
interface IdpMappingTableProps {
type: "Role" | "Group";
rowCount: number;
children: React.ReactNode;
}
export const IdpMappingTable: FC<IdpMappingTableProps> = ({
type,
rowCount,
children,
}) => {
return (
<div className="flex flex-col w-full gap-2">
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="45%">IdP {type.toLocaleLowerCase()}</TableCell>
<TableCell width="55%">
Coder {type.toLocaleLowerCase()}
</TableCell>
<TableCell width="10%" />
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={rowCount === 0}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={`No ${type.toLocaleLowerCase()} mappings`}
isCompact
cta={
<Link
href={docs(
`/admin/users/idp-sync#${type.toLocaleLowerCase()}-sync`,
)}
>
How to setup IdP {type.toLocaleLowerCase()} sync
</Link>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
<div className="flex justify-end">
<div className="text-content-secondary text-xs">
Showing <strong className="text-content-primary">{rowCount}</strong>{" "}
groups
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,250 @@
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import type { Organization, Role, RoleSyncSettings } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import {
MultiSelectCombobox,
type Option,
} from "components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { Plus, Trash } 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(),
auto_create_missing_groups: Yup.boolean(),
mapping: Yup.object()
.test(
"valid-mapping",
"Invalid role sync settings mapping structure",
(value) => {
if (!value) return true;
return Object.entries(value).every(
([key, arr]) =>
typeof key === "string" &&
Array.isArray(arr) &&
arr.every((item) => {
return typeof item === "string";
}),
);
},
)
.default({}),
});
export const IdpRoleSyncForm = ({
roleSyncSettings,
roleMappingCount,
organization,
roles,
onSubmit,
}: IdpRoleSyncFormProps) => {
const form = useFormik<RoleSyncSettings>({
initialValues: {
field: roleSyncSettings?.field ?? "",
mapping: roleSyncSettings?.mapping ?? {},
},
validationSchema: roleSyncValidationSchema,
onSubmit,
enableReinitialize: Boolean(roleSyncSettings),
});
const [idpRoleName, setIdpRoleName] = useState("");
const [coderRoles, setCoderRoles] = useState<Option[]>([]);
const id = useId();
const handleDelete = async (idpOrg: string) => {
const newMapping = Object.fromEntries(
Object.entries(form.values.mapping || {}).filter(
([key]) => key !== idpOrg,
),
);
const newSyncSettings = {
...form.values,
mapping: newMapping,
};
void form.setFieldValue("mapping", newSyncSettings.mapping);
form.handleSubmit();
};
return (
<form onSubmit={form.handleSubmit}>
<fieldset
disabled={form.isSubmitting}
className="flex flex-col border-none gap-8 pt-2"
>
<div className="flex justify-end">
<ExportPolicyButton
syncSettings={roleSyncSettings}
organization={organization}
type="roles"
/>
</div>
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={`${id}-sync-field`}>
Role sync field
</Label>
<div className="flex flex-row items-center gap-5">
<div className="flex flex-row gap-2">
<Input
id={`${id}-sync-field`}
value={form.values.field}
onChange={(event) => {
void form.setFieldValue("field", event.target.value);
}}
className="w-72"
/>
<Button
className="px-6"
type="submit"
disabled={form.isSubmitting || !form.dirty}
onClick={(event) => {
event.preventDefault();
form.handleSubmit();
}}
>
<Spinner loading={form.isSubmitting} />
Save
</Button>
</div>
</div>
<p className="text-content-secondary text-2xs m-0">
If empty, role sync is deactivated
</p>
</div>
{form.errors.field && (
<p className="text-content-danger text-sm m-0">{form.errors.field}</p>
)}
<div className="flex flex-row gap-2 justify-between items-start">
<div className="grid items-center gap-1">
<Label className="text-sm" htmlFor={`${id}-idp-role-name`}>
IdP role name
</Label>
<Input
id={`${id}-idp-role-name`}
value={idpRoleName}
className="w-72"
onChange={(event) => {
setIdpRoleName(event.target.value);
}}
/>
</div>
<div className="grid items-center gap-1 flex-1">
<Label className="text-sm" htmlFor={`${id}-coder-role`}>
Coder role
</Label>
<MultiSelectCombobox
inputProps={{
id: `${id}-coder-role`,
}}
className="min-w-60 max-w-3xl"
value={coderRoles}
onChange={setCoderRoles}
defaultOptions={roles.map((role) => ({
label: role.display_name || role.name,
value: role.name,
}))}
hidePlaceholderWhenSelected
placeholder="Select role"
emptyIndicator={
<p className="text-center text-md text-content-primary">
All roles selected
</p>
}
/>
</div>
<div className="grid grid-rows-[28px_auto]">
<div />
<Button
type="submit"
className="min-w-fit"
disabled={!idpRoleName || coderRoles.length === 0}
onClick={() => {
const newSyncSettings = {
...form.values,
mapping: {
...form.values.mapping,
[idpRoleName]: coderRoles.map((role) => role.value),
},
};
void form.setFieldValue("mapping", newSyncSettings.mapping);
form.handleSubmit();
setIdpRoleName("");
setCoderRoles([]);
}}
>
<Spinner loading={form.isSubmitting}>
<Plus size={14} />
</Spinner>
Add IdP role
</Button>
</div>
</div>
{form.errors.mapping && (
<p className="text-content-danger text-sm m-0">
{Object.values(form.errors.mapping || {})}
</p>
)}
<IdpMappingTable type="Role" rowCount={roleMappingCount}>
{roleSyncSettings?.mapping &&
Object.entries(roleSyncSettings.mapping)
.sort(([a], [b]) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
)
.map(([idpRole, roles]) => (
<RoleRow
key={idpRole}
idpRole={idpRole}
coderRoles={roles}
onDelete={handleDelete}
/>
))}
</IdpMappingTable>
</fieldset>
</form>
);
};
interface RoleRowProps {
idpRole: string;
coderRoles: readonly string[];
onDelete: (idpOrg: string) => void;
}
const RoleRow: FC<RoleRowProps> = ({ idpRole, coderRoles, onDelete }) => {
return (
<TableRow data-testid={`role-${idpRole}`}>
<TableCell>{idpRole}</TableCell>
<TableCell>
<IdpPillList roles={coderRoles} />
</TableCell>
<TableCell>
<Button
variant="outline"
size="icon"
className="text-content-primary"
aria-label="delete"
onClick={() => onDelete(idpRole)}
>
<Trash />
<span className="sr-only">Delete IdP mapping</span>
</Button>
</TableCell>
</TableRow>
);
};

View File

@ -1,31 +0,0 @@
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import type { FC } from "react";
import { docs } from "utils/docs";
export const IdpSyncHelpTooltip: FC = () => {
return (
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipTitle>What is IdP Sync?</HelpTooltipTitle>
<HelpTooltipText>
View the current mappings between your external OIDC provider and
Coder. Use the Coder CLI to configure these mappings.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href={docs("/admin/users/idp-sync")}>
Configure IdP Sync
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltipContent>
</HelpTooltip>
);
};

View File

@ -1,27 +1,30 @@
import LaunchOutlined from "@mui/icons-material/LaunchOutlined";
import Button from "@mui/material/Button";
import { getErrorMessage } from "api/errors";
import { groupsByOrganization } from "api/queries/groups";
import {
groupIdpSyncSettings,
patchGroupSyncSettings,
patchRoleSyncSettings,
roleIdpSyncSettings,
} from "api/queries/organizations";
import { organizationRoles } from "api/queries/roles";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { displayError } from "components/GlobalSnackbar/utils";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Link } from "components/Link/Link";
import { Paywall } from "components/Paywall/Paywall";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useQueries } from "react-query";
import { useMutation, useQueries, useQueryClient } from "react-query";
import { useParams } from "react-router-dom";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import { IdpSyncHelpTooltip } from "./IdpSyncHelpTooltip";
import IdpSyncPageView from "./IdpSyncPageView";
export const IdpSyncPage: FC = () => {
const queryClient = useQueryClient();
const { organization: organizationName } = useParams() as {
organization: string;
};
@ -30,20 +33,34 @@ export const IdpSyncPage: FC = () => {
const { organizations } = useOrganizationSettings();
const organization = organizations?.find((o) => o.name === organizationName);
const [groupIdpSyncSettingsQuery, roleIdpSyncSettingsQuery, groupsQuery] =
useQueries({
queries: [
groupIdpSyncSettings(organizationName),
roleIdpSyncSettings(organizationName),
groupsByOrganization(organizationName),
],
});
const [
groupIdpSyncSettingsQuery,
roleIdpSyncSettingsQuery,
groupsQuery,
rolesQuery,
] = useQueries({
queries: [
groupIdpSyncSettings(organizationName),
roleIdpSyncSettings(organizationName),
groupsByOrganization(organizationName),
organizationRoles(organizationName),
],
});
if (!organization) {
return <EmptyState message="Organization not found" />;
}
const patchGroupSyncSettingsMutation = useMutation(
patchGroupSyncSettings(organizationName, queryClient),
);
const patchRoleSyncSettingsMutation = useMutation(
patchRoleSyncSettings(organizationName, queryClient),
);
const error =
patchGroupSyncSettingsMutation.error ||
patchRoleSyncSettingsMutation.error ||
groupIdpSyncSettingsQuery.error ||
roleIdpSyncSettingsQuery.error ||
groupsQuery.error;
@ -61,44 +78,64 @@ export const IdpSyncPage: FC = () => {
<title>{pageTitle("IdP Sync")}</title>
</Helmet>
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<SettingsHeader
title="IdP Sync"
description="Group and role sync mappings (configured using Coder CLI)."
tooltip={<IdpSyncHelpTooltip />}
/>
<Button
startIcon={<LaunchOutlined />}
component="a"
href={docs("/admin/users/idp-sync")}
target="_blank"
>
Setup IdP Sync
</Button>
</Stack>
<ChooseOne>
<Cond condition={!isIdpSyncEnabled}>
<Paywall
message="IdP Sync"
description="Configure group and role mappings to manage permissions outside of Coder. You need an Premium license to use this feature."
documentationLink={docs("/admin/users/idp-sync")}
/>
</Cond>
<Cond>
<IdpSyncPageView
groupSyncSettings={groupIdpSyncSettingsQuery.data}
roleSyncSettings={roleIdpSyncSettingsQuery.data}
groups={groupsQuery.data}
groupsMap={groupsMap}
organization={organization}
error={error}
/>
</Cond>
</ChooseOne>
<div className="flex flex-col gap-12">
<header className="flex flex-row items-baseline justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-3xl m-0">IdP Sync</h1>
<p className="flex flex-row gap-1 text-sm text-content-secondary font-medium m-0">
Automatically assign groups or roles to a user based on their IdP
claims.
<Link href={docs("/admin/users/idp-sync")}>View docs</Link>
</p>
</div>
</header>
<ChooseOne>
<Cond condition={!isIdpSyncEnabled}>
<Paywall
message="IdP Sync"
description="Configure group and role mappings to manage permissions outside of Coder. You need an Premium license to use this feature."
documentationLink={docs("/admin/users/idp-sync")}
/>
</Cond>
<Cond>
<IdpSyncPageView
groupSyncSettings={groupIdpSyncSettingsQuery.data}
roleSyncSettings={roleIdpSyncSettingsQuery.data}
groups={groupsQuery.data}
groupsMap={groupsMap}
roles={rolesQuery.data}
organization={organization}
error={error}
onSubmitGroupSyncSettings={async (data) => {
try {
await patchGroupSyncSettingsMutation.mutateAsync(data);
displaySuccess("IdP Group sync settings updated.");
} catch (error) {
displayError(
getErrorMessage(
error,
"Failed to update IdP group sync settings",
),
);
}
}}
onSubmitRoleSyncSettings={async (data) => {
try {
await patchRoleSyncSettingsMutation.mutateAsync(data);
displaySuccess("IdP Role sync settings updated.");
} catch (error) {
displayError(
getErrorMessage(
error,
"Failed to update IdP role sync settings",
),
);
}
}}
/>
</Cond>
</ChooseOne>
</div>
</>
);
};

View File

@ -83,8 +83,8 @@ export const RolesTab: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const rolesTab = await canvas.findByText("Role Sync Settings");
const rolesTab = await canvas.findByText("Role sync settings");
await user.click(rolesTab);
await expect(canvas.findByText("IdP Role")).resolves.toBeVisible();
await expect(canvas.findByText("IdP role")).resolves.toBeVisible();
},
};

View File

@ -1,52 +1,28 @@
import type { Interpolation, Theme } from "@emotion/react";
import LaunchOutlined from "@mui/icons-material/LaunchOutlined";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import Skeleton from "@mui/material/Skeleton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
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 {
Group,
GroupSyncSettings,
Organization,
Role,
RoleSyncSettings,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { StatusIndicator } from "components/StatusIndicator/StatusIndicator";
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";
import { IdpGroupSyncForm } from "./IdpGroupSyncForm";
import { IdpRoleSyncForm } from "./IdpRoleSyncForm";
interface IdpSyncPageViewProps {
groupSyncSettings: GroupSyncSettings | undefined;
roleSyncSettings: RoleSyncSettings | undefined;
groups: Group[] | undefined;
groupsMap: Map<string, string>;
roles: Role[] | undefined;
organization: Organization;
error?: unknown;
onSubmitGroupSyncSettings: (data: GroupSyncSettings) => void;
onSubmitRoleSyncSettings: (data: RoleSyncSettings) => void;
}
export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
@ -54,17 +30,14 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
roleSyncSettings,
groups,
groupsMap,
roles,
organization,
error,
onSubmitGroupSyncSettings,
onSubmitRoleSyncSettings,
}) => {
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;
@ -75,359 +48,44 @@ export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
? Object.entries(roleSyncSettings.mapping).length
: 0;
if (error) {
return <ErrorAlert error={error} />;
}
if (!groupSyncSettings || !roleSyncSettings || !groups) {
return <Loader />;
}
return (
<>
<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
/>
<IdpField
name="Regex Filter"
fieldText={
typeof groupSyncSettings?.regex_filter === "string"
? groupSyncSettings.regex_filter
: "none"
}
/>
<IdpField
name="Auto Create"
fieldText={
groupSyncSettings?.field
? 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={groupMappingCount === 0}>
{groupSyncSettings?.mapping &&
Object.entries(groupSyncSettings.mapping)
.sort()
.map(([idpGroup, groups]) => (
<GroupRow
key={idpGroup}
idpGroup={idpGroup}
coderGroup={getGroupNames(groups)}
/>
))}
</IdpMappingTable>
{groupSyncSettings?.legacy_group_name_mapping && (
<section>
<LegacyGroupSyncHeader />
<IdpMappingTable
type="Group"
isEmpty={legacyGroupMappingCount === 0}
>
{Object.entries(groupSyncSettings.legacy_group_name_mapping)
.sort()
.map(([idpGroup, groupId]) => (
<GroupRow
key={idpGroup}
idpGroup={idpGroup}
coderGroup={getGroupNames([groupId])}
/>
))}
</IdpMappingTable>
</section>
)}
</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={roleMappingCount === 0}>
{roleSyncSettings?.mapping &&
Object.entries(roleSyncSettings.mapping)
.sort()
.map(([idpRole, roles]) => (
<RoleRow
key={idpRole}
idpRole={idpRole}
coderRoles={roles}
/>
))}
</IdpMappingTable>
</>
)}
</Stack>
</>
);
};
interface IdpFieldProps {
name: string;
fieldText: string | undefined;
showDisabled?: boolean;
}
const IdpField: FC<IdpFieldProps> = ({
name,
fieldText,
showDisabled = false,
}) => {
return (
<span
css={{
display: "flex",
alignItems: "center",
gap: "16px",
}}
>
<p css={styles.fieldLabel}>{name}</p>
{fieldText ? (
<p css={styles.fieldText}>{fieldText}</p>
<div className="flex flex-col gap-4">
{Boolean(error) && <ErrorAlert error={error} />}
<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" ? (
<IdpGroupSyncForm
groupSyncSettings={groupSyncSettings}
groupMappingCount={groupMappingCount}
legacyGroupMappingCount={legacyGroupMappingCount}
groups={groups}
groupsMap={groupsMap}
organization={organization}
onSubmit={onSubmitGroupSyncSettings}
/>
) : (
showDisabled && (
<div
css={{
display: "flex",
alignItems: "center",
gap: "8px",
height: 0,
}}
>
<StatusIndicator color="error" />
<p>disabled</p>
</div>
)
<IdpRoleSyncForm
roleSyncSettings={roleSyncSettings}
roleMappingCount={roleMappingCount}
roles={roles || []}
organization={organization}
onSubmit={onSubmitRoleSyncSettings}
/>
)}
</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;
children: React.ReactNode;
}
const IdpMappingTable: FC<IdpMappingTableProps> = ({
type,
isEmpty,
children,
}) => {
const isLoading = false;
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="45%">IdP {type}</TableCell>
<TableCell width="55%">Coder {type}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={`No ${type} Mappings`}
isCompact
cta={
<Button
startIcon={<LaunchOutlined />}
component="a"
href={docs("/admin/users/idp-sync")}
target="_blank"
>
How to setup IdP {type} sync
</Button>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
);
};
interface GroupRowProps {
idpGroup: string;
coderGroup: readonly string[];
}
const GroupRow: FC<GroupRowProps> = ({ idpGroup, coderGroup }) => {
return (
<TableRow data-testid={`group-${idpGroup}`}>
<TableCell>{idpGroup}</TableCell>
<TableCell>
<IdpPillList roles={coderGroup} />
</TableCell>
</TableRow>
);
};
interface RoleRowProps {
idpRole: string;
coderRoles: readonly string[];
}
const RoleRow: FC<RoleRowProps> = ({ idpRole, coderRoles }) => {
return (
<TableRow data-testid={`role-${idpRole}`}>
<TableCell>{idpRole}</TableCell>
<TableCell>
<IdpPillList roles={coderRoles} />
</TableCell>
</TableRow>
);
};
const TableLoader = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
);
};
const LegacyGroupSyncHeader: FC = () => {
return (
<h4
css={{
fontSize: 20,
fontWeight: 500,
}}
>
<Stack direction="row" alignItems="end" spacing={1}>
<span>Legacy Group Sync Settings</span>
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipTitle>Legacy Group Sync Settings</HelpTooltipTitle>
<HelpTooltipText>
These settings were configured using environment variables, and
only apply to the default organization. It is now recommended to
configure IdP sync via the CLI, which enables sync to be
configured for any organization, and for those settings to be
persisted without manually setting environment variables.{" "}
<Link href={docs("/admin/users/idp-sync")}>
Learn more&hellip;
</Link>
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
</Stack>
</h4>
);
};
const styles = {
fieldText: {
fontFamily: MONOSPACE_FONT_FAMILY,
whiteSpace: "nowrap",
paddingBottom: ".02rem",
},
fieldLabel: (theme) => ({
color: theme.palette.text.secondary,
}),
fields: () => ({
marginLeft: 16,
fontSize: 14,
}),
tableInfo: () => ({
marginBottom: 16,
}),
} satisfies Record<string, Interpolation<Theme>>;
export default IdpSyncPageView;

View File

@ -3,7 +3,7 @@ import type { AuthorizationRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import {
type FC,
type PropsWithChildren,
@ -108,10 +108,7 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
}}
/>
<Tabs
active={activeTab}
css={{ marginBottom: 40, marginTop: -TAB_PADDING_Y }}
>
<Tabs active={activeTab} className="mb-10 -mt-3">
<Margins>
<TabsList>
<TabLink to="" value="summary">