From a6ba0a904803a4f940da9fcd159f3da8283645f6 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Wed, 18 Mar 2026 14:48:31 +0200 Subject: [PATCH 1/3] feat(user): support userId in invite and inviteBatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename loginId → loginIdOrUserId in invite/inviteBatch so callers can pass either a loginId (creates user if not found) or a userId (resolves to the existing user's loginId and resends the invite, useful for re-inviting). The wire format is unchanged — loginId is still sent in the JSON body. Required For: https://github.com/descope/etc/issues/14641 Co-Authored-By: Claude Sonnet 4.6 --- lib/management/helpers.ts | 3 ++- lib/management/types.ts | 4 +++- lib/management/user.test.ts | 43 +++++++++++++++++++++++++++++++++---- lib/management/user.ts | 20 +++++++++++------ 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/lib/management/helpers.ts b/lib/management/helpers.ts index ee80a7b5e..90d49258e 100644 --- a/lib/management/helpers.ts +++ b/lib/management/helpers.ts @@ -5,8 +5,9 @@ import { User } from './types'; * Transforms user objects by converting roles to roleNames */ export function transformUsersForBatch(users: User[]): any[] { - return users.map(({ roles, ...user }) => ({ + return users.map(({ loginIdOrUserId, roles, ...user }) => ({ ...user, + loginId: loginIdOrUserId, roleNames: roles, })); } diff --git a/lib/management/types.ts b/lib/management/types.ts index 8a493f131..f0b7e9344 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -371,7 +371,9 @@ export type AttributesTypes = string | boolean | number | string[] | null; export type TemplateOptions = Record; // for providing messaging template options (templates that are being sent via email / text message) export type User = { - loginId: string; + /** When a userId is provided, the user must already exist — no new user is created, + * and the invite is sent to the existing user (useful for re-inviting). */ + loginIdOrUserId: string; email?: string; phone?: string; displayName?: string; diff --git a/lib/management/user.test.ts b/lib/management/user.test.ts index 02912a079..56b2dedaf 100644 --- a/lib/management/user.test.ts +++ b/lib/management/user.test.ts @@ -346,6 +346,29 @@ describe('Management User', () => { response: httpResponse, }); }); + + it('should send the correct request when passing a userId', async () => { + const httpResponse = { + ok: true, + json: () => mockMgmtUserResponse, + clone: () => ({ + json: () => Promise.resolve(mockMgmtUserResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const userId = 'U2abc1234567890123456789'; + await management.user.invite(userId, { email: 'a@b.c', sendMail: true }); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.create, { + loginId: userId, + email: 'a@b.c', + roleNames: undefined, + invite: true, + sendMail: true, + }); + }); }); describe('invite batch', () => { @@ -373,8 +396,14 @@ describe('Management User', () => { const resp: SdkResponse = await management.user.inviteBatch( [ - { loginId: 'one', roles: ['r1'], email: 'one@one', password: 'clear', seed: 'aaa' }, - { loginId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed }, + { + loginIdOrUserId: 'one', + roles: ['r1'], + email: 'one@one', + password: 'clear', + seed: 'aaa', + }, + { loginIdOrUserId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed }, ], 'https://invite.me', true, @@ -443,8 +472,14 @@ describe('Management User', () => { }; const resp: SdkResponse = await management.user.createBatch([ - { loginId: 'one', roles: ['r1'], email: 'one@one', password: 'clear', seed: 'aaa' }, - { loginId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed }, + { + loginIdOrUserId: 'one', + roles: ['r1'], + email: 'one@one', + password: 'clear', + seed: 'aaa', + }, + { loginIdOrUserId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed }, ]); expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.createBatch, { diff --git a/lib/management/user.ts b/lib/management/user.ts index 2323fad3e..e51b4d996 100644 --- a/lib/management/user.ts +++ b/lib/management/user.ts @@ -214,8 +214,14 @@ const withUser = (httpClient: HttpClient) => { /* Create Test User End */ /* Invite User */ + /** + * Create a new user and invite them to set up their credentials. + * When loginIdOrUserId is a loginId, a new user is created if one doesn't already exist. + * When loginIdOrUserId is a userId, the user must already exist — no new user is created, + * and the invite is sent to the existing user (useful for re-inviting). + */ function invite( - loginId: string, + loginIdOrUserId: string, options?: UserOptions & { inviteUrl?: string; sendMail?: boolean; // send invite via mail, default is according to project settings @@ -225,7 +231,7 @@ const withUser = (httpClient: HttpClient) => { }, ): Promise>; function invite( - loginId: string, + loginIdOrUserId: string, email?: string, phone?: string, displayName?: string, @@ -246,7 +252,7 @@ const withUser = (httpClient: HttpClient) => { ): Promise>; function invite( - loginId: string, + loginIdOrUserId: string, emailOrOptions?: string | UserOptions, phone?: string, displayName?: string, @@ -266,12 +272,12 @@ const withUser = (httpClient: HttpClient) => { templateId?: string, ): Promise> { // We support both the old and new parameters forms of invite user - // 1. The new form - invite(loginId, { email, phone, ... }}) - // 2. The old form - invite(loginId, email, phone, ...) + // 1. The new form - invite(loginIdOrUserId, { email, phone, ... }}) + // 2. The old form - invite(loginIdOrUserId, email, phone, ...) const body = typeof emailOrOptions === 'string' ? { - loginId, + loginId: loginIdOrUserId, email: emailOrOptions, phone, displayName, @@ -292,7 +298,7 @@ const withUser = (httpClient: HttpClient) => { templateId, } : { - loginId, + loginId: loginIdOrUserId, ...emailOrOptions, roleNames: emailOrOptions?.roles, roles: undefined, From a13d5fac5023d4e942d9b2b1422001e70d4d581e Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Tue, 24 Mar 2026 11:44:57 +0200 Subject: [PATCH 2/3] fix(user): make User type backwards compatible by deprecating loginId Keep loginId as a deprecated alias alongside the new loginIdOrUserId field using a union type (same pattern as PatchUserOptionsUsingIdentifier), so existing callers of createBatch/inviteBatch are not broken. Co-Authored-By: Claude Sonnet 4.6 --- lib/management/helpers.ts | 4 ++-- lib/management/types.ts | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/management/helpers.ts b/lib/management/helpers.ts index 90d49258e..fa7ecde06 100644 --- a/lib/management/helpers.ts +++ b/lib/management/helpers.ts @@ -5,9 +5,9 @@ import { User } from './types'; * Transforms user objects by converting roles to roleNames */ export function transformUsersForBatch(users: User[]): any[] { - return users.map(({ loginIdOrUserId, roles, ...user }) => ({ + return users.map(({ loginIdOrUserId, loginId, roles, ...user }) => ({ ...user, - loginId: loginIdOrUserId, + loginId: loginIdOrUserId ?? loginId, roleNames: roles, })); } diff --git a/lib/management/types.ts b/lib/management/types.ts index f0b7e9344..26a4094af 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -371,9 +371,6 @@ export type AttributesTypes = string | boolean | number | string[] | null; export type TemplateOptions = Record; // for providing messaging template options (templates that are being sent via email / text message) export type User = { - /** When a userId is provided, the user must already exist — no new user is created, - * and the invite is sent to the existing user (useful for re-inviting). */ - loginIdOrUserId: string; email?: string; phone?: string; displayName?: string; @@ -390,7 +387,21 @@ export type User = { seed?: string; // a TOTP seed to set for the user in case of batch invite status?: UserStatus; // the status of the user (enabled, disabled, invited, expired) createdTime?: number; // the time the user was created in seconds since epoch -}; +} & ( + | { + /** The login ID or user ID of the user. When a userId is provided, the user must + * already exist — no new user is created, and the invite is sent to the existing + * user (useful for re-inviting). */ + loginIdOrUserId: string; + /** @deprecated Use loginIdOrUserId instead */ + loginId?: string; + } + | { + /** @deprecated Use loginIdOrUserId instead */ + loginId: string; + loginIdOrUserId?: string; + } +); // The kind of prehashed password to set for a user (only one should be set) export type UserPasswordHashed = { From 31dcb932fdd4140893749d6ba09f877104ef7976 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Tue, 24 Mar 2026 11:48:51 +0200 Subject: [PATCH 3/3] test(user): add backwards compat regression tests for deprecated loginId Verify that inviteBatch and createBatch still correctly map the old loginId field to the wire payload, so JS consumers are not silently broken. Co-Authored-By: Claude Sonnet 4.6 --- lib/management/user.test.ts | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/lib/management/user.test.ts b/lib/management/user.test.ts index 14b07f842..77cde9b2c 100644 --- a/lib/management/user.test.ts +++ b/lib/management/user.test.ts @@ -446,6 +446,50 @@ describe('Management User', () => { response: httpResponse, }); }); + + it('should support deprecated loginId field for backwards compatibility (createBatch)', async () => { + const httpResponse = { + ok: true, + json: () => mockMgmtInviteBatchResponse, + clone: () => ({ + json: () => Promise.resolve(mockMgmtInviteBatchResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.user.createBatch([ + { loginId: 'legacy@user.com', roles: ['r1'], email: 'legacy@user.com' }, + ]); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.createBatch, { + users: [{ loginId: 'legacy@user.com', roleNames: ['r1'], email: 'legacy@user.com' }], + }); + }); + + it('should support deprecated loginId field for backwards compatibility', async () => { + const httpResponse = { + ok: true, + json: () => mockMgmtInviteBatchResponse, + clone: () => ({ + json: () => Promise.resolve(mockMgmtInviteBatchResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.user.inviteBatch( + [{ loginId: 'legacy@user.com', roles: ['r1'], email: 'legacy@user.com' }], + 'https://invite.me', + ); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.createBatch, { + users: [{ loginId: 'legacy@user.com', roleNames: ['r1'], email: 'legacy@user.com' }], + invite: true, + inviteUrl: 'https://invite.me', + sendMail: undefined, + }); + }); }); describe('create batch', () => {