Skip to content

Commit 717dde6

Browse files
yosiharanclaude
andauthored
feat(user): support userId in invite and inviteBatch (#696)
* feat(user): support userId in invite and inviteBatch 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: descope/etc#14641 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent db222d0 commit 717dde6

4 files changed

Lines changed: 113 additions & 14 deletions

File tree

lib/management/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { User } from './types';
55
* Transforms user objects by converting roles to roleNames
66
*/
77
export function transformUsersForBatch(users: User[]): any[] {
8-
return users.map(({ roles, ...user }) => ({
8+
return users.map(({ loginIdOrUserId, loginId, roles, ...user }) => ({
99
...user,
10+
loginId: loginIdOrUserId ?? loginId,
1011
roleNames: roles,
1112
}));
1213
}

lib/management/types.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,6 @@ export type AttributesTypes = string | boolean | number | string[] | null;
371371
export type TemplateOptions = Record<string, string>; // for providing messaging template options (templates that are being sent via email / text message)
372372

373373
export type User = {
374-
loginId: string;
375374
email?: string;
376375
phone?: string;
377376
displayName?: string;
@@ -388,7 +387,21 @@ export type User = {
388387
seed?: string; // a TOTP seed to set for the user in case of batch invite
389388
status?: UserStatus; // the status of the user (enabled, disabled, invited, expired)
390389
createdTime?: number; // the time the user was created in seconds since epoch
391-
};
390+
} & (
391+
| {
392+
/** The login ID or user ID of the user. When a userId is provided, the user must
393+
* already exist — no new user is created, and the invite is sent to the existing
394+
* user (useful for re-inviting). */
395+
loginIdOrUserId: string;
396+
/** @deprecated Use loginIdOrUserId instead */
397+
loginId?: string;
398+
}
399+
| {
400+
/** @deprecated Use loginIdOrUserId instead */
401+
loginId: string;
402+
loginIdOrUserId?: string;
403+
}
404+
);
392405

393406
// The kind of prehashed password to set for a user (only one should be set)
394407
export type UserPasswordHashed = {

lib/management/user.test.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,29 @@ describe('Management User', () => {
346346
response: httpResponse,
347347
});
348348
});
349+
350+
it('should send the correct request when passing a userId', async () => {
351+
const httpResponse = {
352+
ok: true,
353+
json: () => mockMgmtUserResponse,
354+
clone: () => ({
355+
json: () => Promise.resolve(mockMgmtUserResponse),
356+
}),
357+
status: 200,
358+
};
359+
mockHttpClient.post.mockResolvedValue(httpResponse);
360+
361+
const userId = 'U2abc1234567890123456789';
362+
await management.user.invite(userId, { email: 'a@b.c', sendMail: true });
363+
364+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.create, {
365+
loginId: userId,
366+
email: 'a@b.c',
367+
roleNames: undefined,
368+
invite: true,
369+
sendMail: true,
370+
});
371+
});
349372
});
350373

351374
describe('invite batch', () => {
@@ -373,8 +396,14 @@ describe('Management User', () => {
373396

374397
const resp: SdkResponse<CreateOrInviteBatchResponse> = await management.user.inviteBatch(
375398
[
376-
{ loginId: 'one', roles: ['r1'], email: 'one@one', password: 'clear', seed: 'aaa' },
377-
{ loginId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed },
399+
{
400+
loginIdOrUserId: 'one',
401+
roles: ['r1'],
402+
email: 'one@one',
403+
password: 'clear',
404+
seed: 'aaa',
405+
},
406+
{ loginIdOrUserId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed },
378407
],
379408
'https://invite.me',
380409
true,
@@ -417,6 +446,50 @@ describe('Management User', () => {
417446
response: httpResponse,
418447
});
419448
});
449+
450+
it('should support deprecated loginId field for backwards compatibility (createBatch)', async () => {
451+
const httpResponse = {
452+
ok: true,
453+
json: () => mockMgmtInviteBatchResponse,
454+
clone: () => ({
455+
json: () => Promise.resolve(mockMgmtInviteBatchResponse),
456+
}),
457+
status: 200,
458+
};
459+
mockHttpClient.post.mockResolvedValue(httpResponse);
460+
461+
await management.user.createBatch([
462+
{ loginId: 'legacy@user.com', roles: ['r1'], email: 'legacy@user.com' },
463+
]);
464+
465+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.createBatch, {
466+
users: [{ loginId: 'legacy@user.com', roleNames: ['r1'], email: 'legacy@user.com' }],
467+
});
468+
});
469+
470+
it('should support deprecated loginId field for backwards compatibility', async () => {
471+
const httpResponse = {
472+
ok: true,
473+
json: () => mockMgmtInviteBatchResponse,
474+
clone: () => ({
475+
json: () => Promise.resolve(mockMgmtInviteBatchResponse),
476+
}),
477+
status: 200,
478+
};
479+
mockHttpClient.post.mockResolvedValue(httpResponse);
480+
481+
await management.user.inviteBatch(
482+
[{ loginId: 'legacy@user.com', roles: ['r1'], email: 'legacy@user.com' }],
483+
'https://invite.me',
484+
);
485+
486+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.createBatch, {
487+
users: [{ loginId: 'legacy@user.com', roleNames: ['r1'], email: 'legacy@user.com' }],
488+
invite: true,
489+
inviteUrl: 'https://invite.me',
490+
sendMail: undefined,
491+
});
492+
});
420493
});
421494

422495
describe('create batch', () => {
@@ -443,8 +516,14 @@ describe('Management User', () => {
443516
};
444517

445518
const resp: SdkResponse<CreateOrInviteBatchResponse> = await management.user.createBatch([
446-
{ loginId: 'one', roles: ['r1'], email: 'one@one', password: 'clear', seed: 'aaa' },
447-
{ loginId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed },
519+
{
520+
loginIdOrUserId: 'one',
521+
roles: ['r1'],
522+
email: 'one@one',
523+
password: 'clear',
524+
seed: 'aaa',
525+
},
526+
{ loginIdOrUserId: 'two', roles: ['r1'], email: 'two@two', hashedPassword: hashed },
448527
]);
449528

450529
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.createBatch, {

lib/management/user.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,14 @@ const withUser = (httpClient: HttpClient) => {
216216
/* Create Test User End */
217217

218218
/* Invite User */
219+
/**
220+
* Create a new user and invite them to set up their credentials.
221+
* When loginIdOrUserId is a loginId, a new user is created if one doesn't already exist.
222+
* When loginIdOrUserId is a userId, the user must already exist — no new user is created,
223+
* and the invite is sent to the existing user (useful for re-inviting).
224+
*/
219225
function invite(
220-
loginId: string,
226+
loginIdOrUserId: string,
221227
options?: UserOptions & {
222228
inviteUrl?: string;
223229
sendMail?: boolean; // send invite via mail, default is according to project settings
@@ -227,7 +233,7 @@ const withUser = (httpClient: HttpClient) => {
227233
},
228234
): Promise<SdkResponse<UserResponse>>;
229235
function invite(
230-
loginId: string,
236+
loginIdOrUserId: string,
231237
email?: string,
232238
phone?: string,
233239
displayName?: string,
@@ -248,7 +254,7 @@ const withUser = (httpClient: HttpClient) => {
248254
): Promise<SdkResponse<UserResponse>>;
249255

250256
function invite(
251-
loginId: string,
257+
loginIdOrUserId: string,
252258
emailOrOptions?: string | UserOptions,
253259
phone?: string,
254260
displayName?: string,
@@ -268,12 +274,12 @@ const withUser = (httpClient: HttpClient) => {
268274
templateId?: string,
269275
): Promise<SdkResponse<UserResponse>> {
270276
// We support both the old and new parameters forms of invite user
271-
// 1. The new form - invite(loginId, { email, phone, ... }})
272-
// 2. The old form - invite(loginId, email, phone, ...)
277+
// 1. The new form - invite(loginIdOrUserId, { email, phone, ... }})
278+
// 2. The old form - invite(loginIdOrUserId, email, phone, ...)
273279
const body =
274280
typeof emailOrOptions === 'string'
275281
? {
276-
loginId,
282+
loginId: loginIdOrUserId,
277283
email: emailOrOptions,
278284
phone,
279285
displayName,
@@ -294,7 +300,7 @@ const withUser = (httpClient: HttpClient) => {
294300
templateId,
295301
}
296302
: {
297-
loginId,
303+
loginId: loginIdOrUserId,
298304
...emailOrOptions,
299305
roleNames: emailOrOptions?.roles,
300306
roles: undefined,

0 commit comments

Comments
 (0)