mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
@ -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,
|
||||
|
@ -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."),
|
||||
|
184
site/e2e/tests/organizations/idpGroupSync.spec.ts
Normal file
184
site/e2e/tests/organizations/idpGroupSync.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
167
site/e2e/tests/organizations/idpRoleSync.spec.ts
Normal file
167
site/e2e/tests/organizations/idpRoleSync.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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
|
||||
*/
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
||||
|
@ -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>>;
|
||||
|
@ -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} />
|
||||
|
@ -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">
|
||||
|
||||
<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)}
|
||||
>
|
||||
|
@ -45,7 +45,6 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
|
||||
canAssignOrgRole,
|
||||
isCustomRolesEnabled,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
{!isCustomRolesEnabled && (
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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…
|
||||
</Link>
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
</h4>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
@ -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…
|
||||
</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;
|
||||
|
@ -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">
|
||||
|
Reference in New Issue
Block a user