From 4fec2caef6dc397a409cde1f384a0c177d6525c9 Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Thu, 27 Nov 2025 13:58:47 +0000 Subject: [PATCH] feat: update SCIM integration to manage team members with Push Groups --- Common/Server/Services/TeamMemberService.ts | 20 +-- Common/Server/Services/TeamService.ts | 3 +- .../Server/Services/TeamMemberService.test.ts | 114 ++++++++++++++++++ Dashboard/src/Pages/Settings/TeamView.tsx | 25 ++-- Dashboard/src/Pages/Settings/Users.tsx | 28 +++-- 5 files changed, 163 insertions(+), 27 deletions(-) diff --git a/Common/Server/Services/TeamMemberService.ts b/Common/Server/Services/TeamMemberService.ts index 3ba1377ece..207cf31d7d 100644 --- a/Common/Server/Services/TeamMemberService.ts +++ b/Common/Server/Services/TeamMemberService.ts @@ -44,10 +44,13 @@ export class TeamMemberService extends DatabaseService { } @CaptureSpan() - private async isSCIMEnabled(projectId: ObjectID): Promise { + private async isSCIMPushGroupsEnabled( + projectId: ObjectID, + ): Promise { const count: PositiveNumber = await ProjectSCIMService.countBy({ query: { projectId: projectId, + enablePushGroups: true, }, props: { isRoot: true, @@ -63,12 +66,12 @@ export class TeamMemberService extends DatabaseService { // Check if SCIM is enabled for the project if ( !createBy.props.isRoot && - (await this.isSCIMEnabled( + (await this.isSCIMPushGroupsEnabled( createBy.data.projectId! || createBy.props.tenantId, )) ) { throw new BadDataException( - "Cannot invite team members when SCIM is enabled for this project.", + "Cannot invite team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.", ); } @@ -311,10 +314,10 @@ export class TeamMemberService extends DatabaseService { !deleteBy.props.isRoot && members.length > 0 && members[0]?.projectId && - (await this.isSCIMEnabled(members[0].projectId)) + (await this.isSCIMPushGroupsEnabled(members[0].projectId)) ) { throw new BadDataException( - "Cannot delete team members when SCIM is enabled for this project.", + "Cannot delete team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.", ); } @@ -346,11 +349,10 @@ export class TeamMemberService extends DatabaseService { }); // Skip the one-member guard when SCIM manages membership for the project. - const isSCIMEnabled: boolean = await this.isSCIMEnabled( - member.projectId!, - ); + const isPushGroupsManaged: boolean = + await this.isSCIMPushGroupsEnabled(member.projectId!); - if (!isSCIMEnabled && membersInTeam.toNumber() <= 1) { + if (!isPushGroupsManaged && membersInTeam.toNumber() <= 1) { throw new BadDataException( Errors.TeamMemberService.ONE_MEMBER_REQUIRED, ); diff --git a/Common/Server/Services/TeamService.ts b/Common/Server/Services/TeamService.ts index 86600f0de7..bfac90e66c 100644 --- a/Common/Server/Services/TeamService.ts +++ b/Common/Server/Services/TeamService.ts @@ -71,6 +71,7 @@ export class Service extends DatabaseService { const scimCount: PositiveNumber = await ProjectSCIMService.countBy({ query: { projectId: projectId, + enablePushGroups: true, }, skip: new PositiveNumber(0), limit: new PositiveNumber(1), @@ -82,7 +83,7 @@ export class Service extends DatabaseService { if (scimCount.toNumber() > 0) { throw new BadDataException( - `Cannot ${data.action} teams when SCIM is enabled for this project.`, + `Cannot ${data.action} teams while SCIM Push Groups is enabled for this project. Disable Push Groups to manage teams from OneUptime.`, ); } } diff --git a/Common/Tests/Server/Services/TeamMemberService.test.ts b/Common/Tests/Server/Services/TeamMemberService.test.ts index 0082a8ca04..076daa5ade 100644 --- a/Common/Tests/Server/Services/TeamMemberService.test.ts +++ b/Common/Tests/Server/Services/TeamMemberService.test.ts @@ -5,6 +5,7 @@ import MailService from "../../../Server/Services/MailService"; import TeamMemberService from "../../../Server/Services/TeamMemberService"; import UserNotificationRuleService from "../../../Server/Services/UserNotificationRuleService"; import UserNotificationSettingService from "../../../Server/Services/UserNotificationSettingService"; +import ProjectSCIMService from "../../../Server/Services/ProjectSCIMService"; import Errors from "../../../Server/Utils/Errors"; import "../TestingUtils/Init"; import ProjectServiceHelper from "../TestingUtils/Services/ProjectServiceHelper"; @@ -334,6 +335,119 @@ describe("TeamMemberService", () => { }, ); }); + + it("should block inviting users when SCIM push groups is enabled", async () => { + const owner: User = await UserServiceHelper.genrateAndSaveRandomUser( + null, + { + isRoot: true, + }, + ); + + const project: Project = + await ProjectServiceHelper.generateAndSaveRandomProject(null, { + isRoot: true, + userId: owner.id!, + }); + + const team: Team = await TeamServiceHelper.generateAndSaveRandomTeam( + { + projectId: new ObjectID(project.id!), + }, + { + isRoot: true, + }, + ); + + const memberUser: User = + await UserServiceHelper.genrateAndSaveRandomUser(null, { + isRoot: true, + }); + + await ProjectSCIMService.create({ + data: { + projectId: new ObjectID(project._id!), + name: "Test SCIM Push Groups", + bearerToken: ObjectID.generate().toString(), + enablePushGroups: true, + }, + props: { + isRoot: true, + }, + }); + + const tm: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember( + { + projectId: new ObjectID(project._id!), + userId: new ObjectID(memberUser._id!), + teamId: new ObjectID(team._id!), + }, + ); + + await expect( + TeamMemberService.create({ + data: tm, + props: { isRoot: false, tenantId: project.id! }, + }), + ).rejects.toThrow(/SCIM Push Groups/i); + }); + + it("should allow inviting users when SCIM push groups is disabled", async () => { + const owner: User = await UserServiceHelper.genrateAndSaveRandomUser( + null, + { + isRoot: true, + }, + ); + + const project: Project = + await ProjectServiceHelper.generateAndSaveRandomProject(null, { + isRoot: true, + userId: owner.id!, + }); + + const team: Team = await TeamServiceHelper.generateAndSaveRandomTeam( + { + projectId: new ObjectID(project.id!), + }, + { + isRoot: true, + }, + ); + + const memberUser: User = + await UserServiceHelper.genrateAndSaveRandomUser(null, { + isRoot: true, + }); + + await ProjectSCIMService.create({ + data: { + projectId: new ObjectID(project._id!), + name: "Test SCIM without Push Groups", + bearerToken: ObjectID.generate().toString(), + enablePushGroups: false, + }, + props: { + isRoot: true, + }, + }); + + const tm: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember( + { + projectId: new ObjectID(project._id!), + userId: new ObjectID(memberUser._id!), + teamId: new ObjectID(team._id!), + }, + ); + + const teamMember: TeamMember = await TeamMemberService.create({ + data: tm, + props: { isRoot: false, tenantId: project.id! }, + }); + + expect(teamMember).toBeDefined(); + expect(teamMember.projectId?.toString()).toEqual(project._id?.toString()); + }); }); describe("onCreateSuccess", () => { diff --git a/Dashboard/src/Pages/Settings/TeamView.tsx b/Dashboard/src/Pages/Settings/TeamView.tsx index b71fdbf46c..d596282c27 100644 --- a/Dashboard/src/Pages/Settings/TeamView.tsx +++ b/Dashboard/src/Pages/Settings/TeamView.tsx @@ -53,7 +53,8 @@ const TeamView: FunctionComponent = ( const complianceStatusTableRef: React.Ref = React.useRef(null); - const [isScimEnabled, setIsScimEnabled] = React.useState(false); + const [isPushGroupsManaged, setIsPushGroupsManaged] = + React.useState(false); React.useEffect(() => { const checkScim: () => Promise = async () => { @@ -65,9 +66,10 @@ const TeamView: FunctionComponent = ( modelType: ProjectSCIM, query: { projectId: props.currentProject._id, + enablePushGroups: true, }, }); - setIsScimEnabled(scimCount > 0); + setIsPushGroupsManaged(scimCount > 0); } catch { // ignore } @@ -327,23 +329,30 @@ const TeamView: FunctionComponent = ( {/* Team Members Table */} + {isPushGroupsManaged && ( + + )} + modelType={TeamMember} id="table-team-member" userPreferencesKey="team-member-table" - isDeleteable={true} + isDeleteable={!isPushGroupsManaged} name="Settings > Team > Member" createVerb={"Invite"} - isCreateable={true} + isCreateable={!isPushGroupsManaged} isViewable={false} query={{ teamId: modelId, projectId: ProjectUtil.getCurrentProjectId()!, }} onBeforeCreate={(item: TeamMember): Promise => { - if (isScimEnabled) { + if (isPushGroupsManaged) { throw new BadDataException( - "Cannot invite users when SCIM is enabled for this project.", + "Cannot invite users while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.", ); } if (!props.currentProject || !props.currentProject._id) { @@ -354,9 +363,9 @@ const TeamView: FunctionComponent = ( return Promise.resolve(item); }} onBeforeDelete={async (item: TeamMember): Promise => { - if (isScimEnabled) { + if (isPushGroupsManaged) { throw new BadDataException( - "Cannot remove team members when SCIM is enabled for this project.", + "Cannot remove team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.", ); } return item; diff --git a/Dashboard/src/Pages/Settings/Users.tsx b/Dashboard/src/Pages/Settings/Users.tsx index eb9df835d8..72b279d69d 100644 --- a/Dashboard/src/Pages/Settings/Users.tsx +++ b/Dashboard/src/Pages/Settings/Users.tsx @@ -21,6 +21,7 @@ import BadDataException from "Common/Types/Exception/BadDataException"; import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM"; import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal"; +import Banner from "Common/UI/Components/Banner/Banner"; const Teams: FunctionComponent = ( props: PageComponentProps, @@ -30,7 +31,8 @@ const Teams: FunctionComponent = ( const [showScimErrorModal, setShowScimErrorModal] = React.useState(false); const [isFilterApplied, setIsFilterApplied] = React.useState(false); - const [isScimEnabled, setIsScimEnabled] = React.useState(false); + const [isPushGroupsManaged, setIsPushGroupsManaged] = + React.useState(false); React.useEffect(() => { const checkScim: () => Promise = async () => { @@ -42,9 +44,10 @@ const Teams: FunctionComponent = ( modelType: ProjectSCIM, query: { projectId: props.currentProject._id, + enablePushGroups: true, }, }); - setIsScimEnabled(scimCount > 0); + setIsPushGroupsManaged(scimCount > 0); } catch { // ignore } @@ -54,12 +57,19 @@ const Teams: FunctionComponent = ( return ( + {isPushGroupsManaged && ( + + )} + modelType={TeamMember} id="teams-table" name="Settings > Users" userPreferencesKey="users-table" - isDeleteable={!isScimEnabled} + isDeleteable={!isPushGroupsManaged} isEditable={false} isCreateable={false} onFilterApplied={(isApplied: boolean) => { @@ -67,9 +77,9 @@ const Teams: FunctionComponent = ( }} isViewable={true} onBeforeDelete={async (item: TeamMember): Promise => { - if (isScimEnabled) { + if (isPushGroupsManaged) { throw new BadDataException( - "Cannot remove team members when SCIM is enabled for this project.", + "Cannot remove team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.", ); } return item; @@ -84,7 +94,7 @@ const Teams: FunctionComponent = ( buttonStyle: ButtonStyleType.NORMAL, icon: IconProp.Add, onClick: () => { - if (isScimEnabled) { + if (isPushGroupsManaged) { setShowScimErrorModal(true); } else { setShowInviteUserModal(true); @@ -185,7 +195,7 @@ const Teams: FunctionComponent = ( }, ]} /> - {showInviteUserModal && !isScimEnabled && ( + {showInviteUserModal && !isPushGroupsManaged && ( modelType={TeamMember} name="Invite New User" @@ -250,8 +260,8 @@ const Teams: FunctionComponent = ( )} {showScimErrorModal && ( { setShowScimErrorModal(false); }}