From 076b977a4eb96f1d5587c036c1a7daffbd8665da Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 13:38:24 +0200 Subject: [PATCH 1/2] feat(web): disable member invites for child organizations Child organizations now manage membership through their parent organization, so direct invites into a child org are no longer allowed. - inviteUserToOrganization rejects child orgs; members.invite maps this to a PRECONDITION_FAILED with a user-facing message - acceptOrganizationInvite rejects redeeming any (including legacy) invitation into a child org - hide the Invite Member button for child orgs in OrganizationMembersCard - update tests to encode the new behavior --- .../organizations/OrganizationMembersCard.tsx | 7 +- .../lib/organizations/organizations.test.ts | 99 +++---------------- .../src/lib/organizations/organizations.ts | 16 ++- .../organization-members-router.test.ts | 22 ++++- .../organization-members-router.ts | 7 ++ 5 files changed, 55 insertions(+), 96 deletions(-) diff --git a/apps/web/src/components/organizations/OrganizationMembersCard.tsx b/apps/web/src/components/organizations/OrganizationMembersCard.tsx index a9a4a751dd..a803853c7a 100644 --- a/apps/web/src/components/organizations/OrganizationMembersCard.tsx +++ b/apps/web/src/components/organizations/OrganizationMembersCard.tsx @@ -355,7 +355,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 c26b7c566e..3d8185657a 100644 --- a/apps/web/src/routers/organizations/organization-members-router.test.ts +++ b/apps/web/src/routers/organizations/organization-members-router.test.ts @@ -1,7 +1,9 @@ import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createOrganization, addUserToOrganization } from '@/lib/organizations/organizations'; -import type { User, Organization } from '@kilocode/db/schema'; +import { db } from '@/lib/drizzle'; +import { organizations, type User, type Organization } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; // Mock the email service to prevent actual API calls during tests jest.mock('@/lib/email', () => ({ @@ -387,6 +389,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 4c190762e4..4fad849260 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -257,6 +257,13 @@ 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', From 1cbbe44d58e202ca2020e36ded7daa3ad7819be9 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 13:53:38 +0200 Subject: [PATCH 2/2] style(web): fix formatting in members router --- .../src/routers/organizations/organization-members-router.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/routers/organizations/organization-members-router.ts b/apps/web/src/routers/organizations/organization-members-router.ts index 1a39b16f6c..0ba1e1d12c 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -413,8 +413,7 @@ export const organizationsMembersRouter = createTRPCRouter({ if (error.message === 'Child organizations cannot invite members') { throw new TRPCError({ code: 'PRECONDITION_FAILED', - message: - 'Child organizations manage membership through their parent organization.', + message: 'Child organizations manage membership through their parent organization.', }); } if (error.message === 'User must join this organization through SSO') {