diff --git a/apps/web/src/components/organizations/OrganizationMembersCard.tsx b/apps/web/src/components/organizations/OrganizationMembersCard.tsx index 86eecef783..307f64200e 100644 --- a/apps/web/src/components/organizations/OrganizationMembersCard.tsx +++ b/apps/web/src/components/organizations/OrganizationMembersCard.tsx @@ -490,7 +490,12 @@ export function OrganizationAdminMembers({ // Show "Add Member" button only for Kilo admins const showAddMemberButton = isKiloAdmin; - const showInviteMemberButton = canInviteMembers(currentUserRole, isKiloAdmin); + // Child organizations manage membership through their parent organization, + // so the self-serve invite flow is hidden for them. + const isChildOrganization = organizationData.parent_organization_id !== null; + + const showInviteMemberButton = + canInviteMembers(currentUserRole, isKiloAdmin) && !isChildOrganization; return ( <> diff --git a/apps/web/src/lib/organizations/organizations.test.ts b/apps/web/src/lib/organizations/organizations.test.ts index a6c1e9ec0f..2bb4bc30bb 100644 --- a/apps/web/src/lib/organizations/organizations.test.ts +++ b/apps/web/src/lib/organizations/organizations.test.ts @@ -897,52 +897,18 @@ describe('Organizations', () => { ).rejects.toThrow('User must join this organization through SSO'); }); - test('creates a WorkOS-bound invitation for a same-domain child user', async () => { + test('rejects invitations into a child organization', async () => { const owner = await insertTestUser(); - const parent = await createOrganization('Parent SSO Org', owner.id); - await db - .update(organizations) - .set({ sso_domain: 'example.com' }) - .where(eq(organizations.id, parent.id)); - const child = await createOrganization('Child Org', owner.id); - await db - .update(organizations) - .set({ parent_organization_id: parent.id }) - .where(eq(organizations.id, child.id)); - - const invitation = await inviteUserToOrganization( - child.id, - owner.id, - 'member@example.com', - 'member' - ); - - expect(invitation.authentication_requirement).toBe('workos'); - expect(invitation.sso_source_organization_id).toBe(parent.id); - }); - - test('keeps external child invitations on default authentication', async () => { - const owner = await insertTestUser(); - const parent = await createOrganization('Parent SSO Org', owner.id); - await db - .update(organizations) - .set({ sso_domain: 'example.com' }) - .where(eq(organizations.id, parent.id)); + const parent = await createOrganization('Parent Org', owner.id); const child = await createOrganization('Child Org', owner.id); await db .update(organizations) .set({ parent_organization_id: parent.id }) .where(eq(organizations.id, child.id)); - const invitation = await inviteUserToOrganization( - child.id, - owner.id, - 'contractor@external.test', - 'member' - ); - - expect(invitation.authentication_requirement).toBe('default'); - expect(invitation.sso_source_organization_id).toBeNull(); + await expect( + inviteUserToOrganization(child.id, owner.id, 'member@example.com', 'member') + ).rejects.toThrow('Child organizations cannot invite members'); }); }); @@ -1450,49 +1416,14 @@ describe('Organizations', () => { expect(storedInvitation?.accepted_at).not.toBeNull(); }); - test('requires matching WorkOS authentication for a child SSO invitation', async () => { - const owner = await insertTestUser(); - const invitee = await insertTestUser({ google_user_email: 'member@example.com' }); - const parent = await createOrganization('Parent SSO Org', owner.id); - await db - .update(organizations) - .set({ sso_domain: 'example.com' }) - .where(eq(organizations.id, parent.id)); - const child = await createOrganization('Child Org', owner.id); - await db - .update(organizations) - .set({ parent_organization_id: parent.id }) - .where(eq(organizations.id, child.id)); - const invitation = await inviteUserToOrganization( - child.id, - owner.id, - invitee.google_user_email, - 'member' - ); - - const rejected = await acceptOrganizationInvite(invitee.id, invitation.token); - expect(rejected).toEqual({ - success: false, - error: 'Invitation requires authentication through organization SSO', - }); - - const accepted = await acceptOrganizationInvite(invitee.id, invitation.token, { - provider: 'workos', - ssoSourceOrganizationId: parent.id, - }); - expect(accepted.success).toBe(true); - }); - - test('rejects a legacy ordinary invitation after child SSO becomes effective', async () => { + test('rejects accepting a pre-existing invitation into a child organization', async () => { const owner = await insertTestUser(); const invitee = await insertTestUser({ google_user_email: 'legacy@example.com' }); - const parent = await createOrganization('Parent SSO Org', owner.id); - await db - .update(organizations) - .set({ sso_domain: 'example.com' }) - .where(eq(organizations.id, parent.id)); + const parent = await createOrganization('Parent Org', owner.id); const child = await createOrganization('Child Org', owner.id); - const token = 'legacy-sso-invitation'; + // Invites can no longer be created for child orgs through the normal flow, + // so insert a pending invite directly, then reparent the org afterwards. + const token = 'legacy-child-invitation'; await db.insert(organization_invitations).values({ organization_id: child.id, email: invitee.google_user_email, @@ -1506,15 +1437,15 @@ describe('Organizations', () => { .set({ parent_organization_id: parent.id }) .where(eq(organizations.id, child.id)); - const result = await acceptOrganizationInvite(invitee.id, token, { - provider: 'workos', - ssoSourceOrganizationId: parent.id, - }); + const result = await acceptOrganizationInvite(invitee.id, token); expect(result).toEqual({ success: false, - error: 'Invitation requires authentication through organization SSO', + error: 'Child organizations cannot accept invitations', }); + + const userOrgs = await getUserOrganizationsWithSeats(invitee.id); + expect(userOrgs).toHaveLength(0); }); test('should accept invitation with owner role', async () => { diff --git a/apps/web/src/lib/organizations/organizations.ts b/apps/web/src/lib/organizations/organizations.ts index 1f2c5b6ff0..7613aacbd7 100644 --- a/apps/web/src/lib/organizations/organizations.ts +++ b/apps/web/src/lib/organizations/organizations.ts @@ -435,12 +435,10 @@ export async function inviteUserToOrganization( if (!organization) { throw new Error('Organization SSO policy is misconfigured'); } + // Child organizations manage membership through their parent organization, + // so direct invitations into a child org are not allowed. if (organization.parentOrganizationId) { - await tx - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.id, organization.parentOrganizationId)) - .for('update'); + throw new Error('Child organizations cannot invite members'); } const policy = await resolveEffectiveOrganizationSsoPolicy(organizationId, tx); @@ -663,12 +661,10 @@ export async function acceptOrganizationInvite( if (!organization) { return failureResult('Organization not found'); } + // Child organizations manage membership through their parent organization. + // Reject any invitation (including pre-existing ones) into a child org. if (organization.parent_organization_id) { - await tx - .select({ id: organizations.id }) - .from(organizations) - .where(eq(organizations.id, organization.parent_organization_id)) - .for('update'); + return failureResult('Child organizations cannot accept invitations'); } const ssoPolicy = await resolveEffectiveOrganizationSsoPolicy(organization.id, tx); diff --git a/apps/web/src/routers/organizations/organization-members-router.test.ts b/apps/web/src/routers/organizations/organization-members-router.test.ts index d50fc26d70..86e7bda1a8 100644 --- a/apps/web/src/routers/organizations/organization-members-router.test.ts +++ b/apps/web/src/routers/organizations/organization-members-router.test.ts @@ -568,6 +568,24 @@ describe('organizations members trpc router', () => { expect(result.acceptInviteUrl).toMatch(/^https?:\/\/.+\/users\/accept-invite\/.+$/); }); + it('should reject inviting members into a child organization', async () => { + const childOrg = await createOrganization('Child Members Organization', regularUser.id); + await db + .update(organizations) + .set({ parent_organization_id: testOrganization.id }) + .where(eq(organizations.id, childOrg.id)); + + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.organizations.members.invite({ + organizationId: childOrg.id, + email: 'child-org-invite@example.com', + role: 'member', + }) + ).rejects.toThrow('Child organizations manage membership through their parent organization.'); + }); + it('should reject billing managers inviting owners', async () => { const caller = await createCallerForUser(billingManagerUser.id); diff --git a/apps/web/src/routers/organizations/organization-members-router.ts b/apps/web/src/routers/organizations/organization-members-router.ts index ea16f3b16a..0ba1e1d12c 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -410,6 +410,12 @@ export const organizationsMembersRouter = createTRPCRouter({ message: 'This user is already a member of this organization', }); } + if (error.message === 'Child organizations cannot invite members') { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Child organizations manage membership through their parent organization.', + }); + } if (error.message === 'User must join this organization through SSO') { throw new TRPCError({ code: 'FORBIDDEN',