From d75c0eda09873d2233b28183a974e65159004b8f Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Wed, 8 Jul 2026 10:42:57 +0200 Subject: [PATCH 1/2] feat(organizations): disable personal account for new users joining via invite When a brand-new account is created specifically to accept an organization invite, set personal_account_disabled to true on join. Existing users keep their current value. --- .../app/users/accept-invite/[token]/page.tsx | 13 ++++-- .../lib/organizations/organizations.test.ts | 43 +++++++++++++++++++ .../src/lib/organizations/organizations.ts | 14 +++++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/users/accept-invite/[token]/page.tsx b/apps/web/src/app/users/accept-invite/[token]/page.tsx index 2ea714ba11..864bc0c516 100644 --- a/apps/web/src/app/users/accept-invite/[token]/page.tsx +++ b/apps/web/src/app/users/accept-invite/[token]/page.tsx @@ -19,10 +19,15 @@ export default async function AcceptInvitePage({ params }: AcceptInvitePageProps ); const session = await getServerSession(authOptions); - const result = await acceptOrganizationInvite(user.id, token, { - provider: session?.authProvider, - ssoSourceOrganizationId: session?.ssoSourceOrganizationId, - }); + const result = await acceptOrganizationInvite( + user.id, + token, + { + provider: session?.authProvider, + ssoSourceOrganizationId: session?.ssoSourceOrganizationId, + }, + session?.isNewUser ?? false + ); if (result.success) { // we need to set user to be styched if they're not so they don't get styched since they were invited to an org diff --git a/apps/web/src/lib/organizations/organizations.test.ts b/apps/web/src/lib/organizations/organizations.test.ts index a67d50a5c0..75b7090e4d 100644 --- a/apps/web/src/lib/organizations/organizations.test.ts +++ b/apps/web/src/lib/organizations/organizations.test.ts @@ -7,6 +7,7 @@ import { organization_membership_removals, organization_seats_purchases, organization_user_limits, + kilocode_users, } from '@kilocode/db/schema'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { eq, and } from 'drizzle-orm'; @@ -1597,6 +1598,48 @@ describe('Organizations', () => { expect(storedInvitation?.accepted_at).not.toBeNull(); }); + test('disables the personal account for a new user joining via invite', async () => { + const owner = await insertTestUser(); + const invitee = await insertTestUser(); + const organization = await createOrganization('Test Org', owner.id); + + const invitation = await inviteUserToOrganization( + organization.id, + owner.id, + invitee.google_user_email, + 'member' + ); + + const result = await acceptOrganizationInvite(invitee.id, invitation.token, {}, true); + expect(result.success).toBe(true); + + const updatedInvitee = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, invitee.id), + }); + expect(updatedInvitee?.personal_account_disabled).toBe(true); + }); + + test('leaves the personal account untouched for an existing user joining via invite', async () => { + const owner = await insertTestUser(); + const invitee = await insertTestUser(); + const organization = await createOrganization('Test Org', owner.id); + + const invitation = await inviteUserToOrganization( + organization.id, + owner.id, + invitee.google_user_email, + 'member' + ); + + const result = await acceptOrganizationInvite(invitee.id, invitation.token); + expect(result.success).toBe(true); + + const updatedInvitee = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, invitee.id), + }); + expect(updatedInvitee?.personal_account_disabled).toBe(false); + }); + 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' }); diff --git a/apps/web/src/lib/organizations/organizations.ts b/apps/web/src/lib/organizations/organizations.ts index 25ba7c4e59..8f9c069117 100644 --- a/apps/web/src/lib/organizations/organizations.ts +++ b/apps/web/src/lib/organizations/organizations.ts @@ -661,7 +661,10 @@ export type InvitationAuthenticationContext = { export async function acceptOrganizationInvite( userId: User['id'], inviteToken: string, - authentication: InvitationAuthenticationContext = {} + authentication: InvitationAuthenticationContext = {}, + // When a brand-new account is created specifically to accept an org invite, + // we disable its personal account. Existing users keep their current value. + isNewUser = false ): Promise { try { const result = await db.transaction(async tx => { @@ -781,6 +784,15 @@ export async function acceptOrganizationInvite( invited_by: invitation.invited_by, }); + // A newly created account that joins an organization via invite should + // not have a personal account. Existing users keep their current value. + if (isNewUser) { + await tx + .update(kilocode_users) + .set({ personal_account_disabled: true }) + .where(eq(kilocode_users.id, userId)); + } + // Clear any previous removal record so the user isn't treated as "removed" // by subsequent webhook events (Subscription Lifecycle 2) await tx From 7e8ede26c141602da198cd8d7676e6d70cc6d74b Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Wed, 8 Jul 2026 10:52:51 +0200 Subject: [PATCH 2/2] fix(organizations): tie personal-account disabling to invite recency Replace the broad session-level isNewUser flag with a deterministic check: disable the personal account only when the invitation predates the account, i.e. the account was created because of an already-pending invite. This avoids disabling personal accounts for users who signed up independently and are later invited to an organization. --- .../src/app/users/accept-invite/[token]/page.tsx | 13 ++++--------- .../src/lib/organizations/organizations.test.ts | 13 ++++++++++--- apps/web/src/lib/organizations/organizations.ts | 16 +++++++++------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/users/accept-invite/[token]/page.tsx b/apps/web/src/app/users/accept-invite/[token]/page.tsx index 864bc0c516..2ea714ba11 100644 --- a/apps/web/src/app/users/accept-invite/[token]/page.tsx +++ b/apps/web/src/app/users/accept-invite/[token]/page.tsx @@ -19,15 +19,10 @@ export default async function AcceptInvitePage({ params }: AcceptInvitePageProps ); const session = await getServerSession(authOptions); - const result = await acceptOrganizationInvite( - user.id, - token, - { - provider: session?.authProvider, - ssoSourceOrganizationId: session?.ssoSourceOrganizationId, - }, - session?.isNewUser ?? false - ); + const result = await acceptOrganizationInvite(user.id, token, { + provider: session?.authProvider, + ssoSourceOrganizationId: session?.ssoSourceOrganizationId, + }); if (result.success) { // we need to set user to be styched if they're not so they don't get styched since they were invited to an org diff --git a/apps/web/src/lib/organizations/organizations.test.ts b/apps/web/src/lib/organizations/organizations.test.ts index 75b7090e4d..78e6ef36f9 100644 --- a/apps/web/src/lib/organizations/organizations.test.ts +++ b/apps/web/src/lib/organizations/organizations.test.ts @@ -1598,7 +1598,7 @@ describe('Organizations', () => { expect(storedInvitation?.accepted_at).not.toBeNull(); }); - test('disables the personal account for a new user joining via invite', async () => { + test('disables the personal account when the account was created after the invite', async () => { const owner = await insertTestUser(); const invitee = await insertTestUser(); const organization = await createOrganization('Test Org', owner.id); @@ -1609,8 +1609,13 @@ describe('Organizations', () => { invitee.google_user_email, 'member' ); + // Simulate a brand-new account created to accept an already-pending invite. + await db + .update(organization_invitations) + .set({ created_at: sql`NOW() - INTERVAL '1 hour'` }) + .where(eq(organization_invitations.id, invitation.id)); - const result = await acceptOrganizationInvite(invitee.id, invitation.token, {}, true); + const result = await acceptOrganizationInvite(invitee.id, invitation.token); expect(result.success).toBe(true); const updatedInvitee = await db.query.kilocode_users.findFirst({ @@ -1619,11 +1624,13 @@ describe('Organizations', () => { expect(updatedInvitee?.personal_account_disabled).toBe(true); }); - test('leaves the personal account untouched for an existing user joining via invite', async () => { + test('leaves the personal account untouched when the account predates the invite', async () => { const owner = await insertTestUser(); const invitee = await insertTestUser(); const organization = await createOrganization('Test Org', owner.id); + // The invitation is created after the invitee's account, matching an + // existing user who is later invited to an organization. const invitation = await inviteUserToOrganization( organization.id, owner.id, diff --git a/apps/web/src/lib/organizations/organizations.ts b/apps/web/src/lib/organizations/organizations.ts index 8f9c069117..c78788c645 100644 --- a/apps/web/src/lib/organizations/organizations.ts +++ b/apps/web/src/lib/organizations/organizations.ts @@ -661,10 +661,7 @@ export type InvitationAuthenticationContext = { export async function acceptOrganizationInvite( userId: User['id'], inviteToken: string, - authentication: InvitationAuthenticationContext = {}, - // When a brand-new account is created specifically to accept an org invite, - // we disable its personal account. Existing users keep their current value. - isNewUser = false + authentication: InvitationAuthenticationContext = {} ): Promise { try { const result = await db.transaction(async tx => { @@ -695,6 +692,7 @@ export async function acceptOrganizationInvite( .select({ email: kilocode_users.google_user_email, normalizedEmail: kilocode_users.normalized_email, + createdAt: kilocode_users.created_at, }) .from(kilocode_users) .where(eq(kilocode_users.id, userId)) @@ -784,9 +782,13 @@ export async function acceptOrganizationInvite( invited_by: invitation.invited_by, }); - // A newly created account that joins an organization via invite should - // not have a personal account. Existing users keep their current value. - if (isNewUser) { + // If the invitation predates the account, the account was created after + // (i.e. because of) a pending invite: this is a brand-new user joining an + // organization via invite, so disable their personal account. Existing + // users — whose account predates the invitation — keep their value. + const accountCreatedForInvite = + new Date(invitation.created_at).getTime() < new Date(acceptingUser.createdAt).getTime(); + if (accountCreatedForInvite) { await tx .update(kilocode_users) .set({ personal_account_disabled: true })