diff --git a/README.md b/README.md index fc24f2d19..e1a680a1b 100644 --- a/README.md +++ b/README.md @@ -702,6 +702,66 @@ const resWithActor = await descopeClient.management.tenant.generateSSOConfigurat console.log(resWithActor.adminSSOConfigurationLink); ``` +### Manage Families + +You can create, update, delete or search families, manage the users linked to them, and +impersonate a family dependent (a shadow-profile user with no login credentials of their own): + +```typescript +// Create a family. familyId is optional - a random one is generated when omitted. +const family = await descopeClient.management.family.create('My Family', { + customAttributeName: 'val', +}); + +// Update will override all provided fields as is. Omitted fields are left unchanged. +await descopeClient.management.family.update(family.data.id, 'My Family', { + customAttributeName: 'val2', +}); + +// Family deletion cannot be undone. Use carefully. +await descopeClient.management.family.delete(family.data.id); + +// Search families according to various parameters. Called with no options, returns all families. +const searchRes = await descopeClient.management.family.search({ freeText: 'My Family' }); +searchRes.data.forEach((f) => { + // do something +}); + +// Create a dependent (shadow profile) user in a family - a user with no login credentials of +// their own. When loginId is omitted it is derived from name; email/phone are never used as the +// login ID since they are not unique - a dependent may share them with their guardian. +const dependent = await descopeClient.management.family.createDependent(family.data.id, { + name: 'My Dependent', +}); + +// Dependent deletion cannot be undone. The family is inferred from the dependent. Regular +// (non-dependent) family members are removed via user.removeFamilies, not deleted. +await descopeClient.management.family.deleteDependent(dependent.data.userId); + +// Add a user to one or more families. Each entry may also set the user's roles and family-scoped +// attributes for that family in the same call; omitting roleNames or familyScopedAttributes on a +// family the user already belongs to leaves them unchanged. +await descopeClient.management.user.addFamilies('user-login-id', [ + { familyId: family.data.id, roleNames: ['role1'] }, +]); + +// Remove a user from one or more families. +await descopeClient.management.user.removeFamilies('user-login-id', [family.data.id]); + +// Impersonate a family dependent. The impersonator (by user ID or login ID) must be a member of +// the dependent's family and hold the family impersonate-dependents permission there. +const impersonateRes = await descopeClient.management.family.impersonateDependent( + 'admin-user-id', + 'dependent-login-id', + family.data.id, // optional - scopes the impersonated session to this family +); +console.log(impersonateRes.data.jwt); + +// Stop impersonating a family dependent and return to the acting admin's own session. +const stopRes = await descopeClient.management.family.stopImpersonation(impersonateRes.data.jwt); +console.log(stopRes.data.jwt); +``` + ### Manage Password You can read and update any tenant password settings and policy: diff --git a/lib/management/family.test.ts b/lib/management/family.test.ts new file mode 100644 index 000000000..c3b573ca4 --- /dev/null +++ b/lib/management/family.test.ts @@ -0,0 +1,321 @@ +import { SdkResponse } from '@descope/core-js-sdk'; +import withManagement from '.'; +import apiPaths from './paths'; +import { Family, UpdateJWTResponse } from './types'; +import { mockHttpClient, resetMockHttpClient } from './testutils'; + +const management = withManagement(mockHttpClient); + +const mockFamily: Family = { + id: 'f1', + name: 'family1', + customAttributes: { customAttr: 'value' }, + disabled: false, + photo: 'http://dummy.com/photo.png', + createdTime: 1, +}; + +const mockFamilies: Family[] = [ + mockFamily, + { id: 'f2', name: 'family2', createdTime: 1 }, + { id: 'f3', name: 'family3', createdTime: 1 }, +]; + +const mockUserResponse = { + userId: 'u1', + loginIds: ['lid'], + verifiedEmail: false, + verifiedPhone: false, +}; + +describe('Management Family', () => { + afterEach(() => { + jest.clearAllMocks(); + resetMockHttpClient(); + }); + + describe('create', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({ family: mockFamily }), + clone: () => ({ + json: () => Promise.resolve({ family: mockFamily }), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.family.create( + 'family1', + { customAttr: 'value' }, + 'http://dummy.com/photo.png', + false, + 'f1', + ); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.create, { + name: 'family1', + customAttributes: { customAttr: 'value' }, + photo: 'http://dummy.com/photo.png', + disabled: false, + familyId: 'f1', + }); + + expect(resp).toEqual({ + code: 200, + data: mockFamily, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('update', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({ family: mockFamily }), + clone: () => ({ + json: () => Promise.resolve({ family: mockFamily }), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.family.update( + 'f1', + 'family1', + { customAttr: 'value' }, + 'http://dummy.com/photo.png', + false, + ); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.update, { + id: 'f1', + name: 'family1', + customAttributes: { customAttr: 'value' }, + photo: 'http://dummy.com/photo.png', + disabled: false, + }); + + expect(resp).toEqual({ + code: 200, + data: mockFamily, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('delete', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({}), + clone: () => ({ + json: () => Promise.resolve({}), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp = await management.family.delete('f1'); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.delete, { id: 'f1' }); + + expect(resp).toEqual({ + code: 200, + data: {}, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('search', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({ families: mockFamilies }), + clone: () => ({ + json: () => Promise.resolve({ families: mockFamilies }), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.family.search({ + familyIds: ['f1'], + freeText: 'fam', + familyNames: ['family1'], + page: 0, + size: 10, + customAttributes: { customAttr: 'value' }, + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.family.search, + { + familyIds: ['f1'], + freeText: 'fam', + familyNames: ['family1'], + page: 0, + size: 10, + customAttributes: { customAttr: 'value' }, + }, + {}, + ); + + expect(resp).toEqual({ + code: 200, + data: mockFamilies, + ok: true, + response: httpResponse, + }); + }); + + it('should send an empty body when called without options', async () => { + const httpResponse = { + ok: true, + json: () => ({ families: mockFamilies }), + clone: () => ({ + json: () => Promise.resolve({ families: mockFamilies }), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.family.search(); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.search, {}, {}); + }); + }); + + describe('createDependent', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({ user: mockUserResponse }), + clone: () => ({ + json: () => Promise.resolve({ user: mockUserResponse }), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp = await management.family.createDependent('f1', { + name: 'dependent', + email: 'dependent@example.com', + familyScopedAttributes: { f1: { customAttr: 'value' } }, + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.dependent.create, { + familyId: 'f1', + name: 'dependent', + email: 'dependent@example.com', + familyScopedAttributes: { f1: { customAttr: 'value' } }, + }); + + expect(resp).toEqual({ + code: 200, + data: mockUserResponse, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('deleteDependent', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({}), + clone: () => ({ + json: () => Promise.resolve({}), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp = await management.family.deleteDependent('u1'); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.dependent.delete, { + userId: 'u1', + }); + + expect(resp).toEqual({ + code: 200, + data: {}, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('impersonateDependent', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({ jwt: 'jwt123' }), + clone: () => ({ + json: () => Promise.resolve({ jwt: 'jwt123' }), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.family.impersonateDependent( + 'admin-uid', + 'dependent-lid', + 'f1', + ); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.impersonate, { + impersonatorUserIdOrLoginId: 'admin-uid', + dependentLoginId: 'dependent-lid', + selectedFamily: 'f1', + }); + + expect(resp).toEqual({ + code: 200, + data: { jwt: 'jwt123' }, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('stopImpersonation', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => ({ jwt: 'jwt123' }), + clone: () => ({ + json: () => Promise.resolve({ jwt: 'jwt123' }), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.family.stopImpersonation( + 'jwt123', + { k: 'v' }, + 60, + ); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.family.stopImpersonation, { + jwt: 'jwt123', + customClaims: { k: 'v' }, + refreshDuration: 60, + }); + + expect(resp).toEqual({ + code: 200, + data: { jwt: 'jwt123' }, + ok: true, + response: httpResponse, + }); + }); + }); +}); diff --git a/lib/management/family.ts b/lib/management/family.ts new file mode 100644 index 000000000..bf274c905 --- /dev/null +++ b/lib/management/family.ts @@ -0,0 +1,105 @@ +import { SdkResponse, transformResponse, HttpClient, UserResponse } from '@descope/core-js-sdk'; +import apiPaths from './paths'; +import { + Family, + AttributesTypes, + SearchFamiliesOptions, + CreateFamilyDependentOptions, + UpdateJWTResponse, +} from './types'; + +type SingleFamilyResponse = { + family: Family; +}; + +type MultipleFamilyResponse = { + families: Family[]; +}; + +type SingleUserResponse = { + user: UserResponse; +}; + +const withFamily = (httpClient: HttpClient) => ({ + create: ( + name: string, + customAttributes?: Record, + photo?: string, + disabled?: boolean, + // Optional caller-supplied family ID; a random one is generated when omitted. + familyId?: string, + ): Promise> => + transformResponse( + httpClient.post(apiPaths.family.create, { + name, + customAttributes, + photo, + disabled, + familyId, + }), + (data) => data.family, + ), + /** Update will override all provided fields as is. Omitted fields are left unchanged. */ + update: ( + id: string, + name?: string, + customAttributes?: Record, + photo?: string, + disabled?: boolean, + ): Promise> => + transformResponse( + httpClient.post(apiPaths.family.update, { id, name, customAttributes, photo, disabled }), + (data) => data.family, + ), + /** Family deletion cannot be undone. Use carefully. */ + delete: (id: string): Promise> => + transformResponse(httpClient.post(apiPaths.family.delete, { id })), + /** Search families according to various parameters. Called with no options, returns all families. */ + search: (options?: SearchFamiliesOptions): Promise> => + transformResponse( + httpClient.post(apiPaths.family.search, options ?? {}, {}), + (data) => data.families, + ), + /** Create a dependent (shadow profile) user in a family - a user with no login credentials of their own. */ + createDependent: ( + familyId: string, + options?: CreateFamilyDependentOptions, + ): Promise> => + transformResponse( + httpClient.post(apiPaths.family.dependent.create, { familyId, ...options }), + (data) => data.user, + ), + /** Delete a dependent user. The family is inferred from the dependent. Regular (non-dependent) + * family members are removed via `user.removeFamilies`, not deleted. */ + deleteDependent: (userId: string): Promise> => + transformResponse(httpClient.post(apiPaths.family.dependent.delete, { userId })), + /** + * Impersonate a family dependent. The impersonator (by user ID or login ID) must be a member of + * the dependent's family and hold the family impersonate-dependents permission there. + * @param selectedFamily optional family to scope the impersonated session to (stamped as the + * current-family claim); when set it must be the dependent's family. + */ + impersonateDependent: ( + impersonatorUserIdOrLoginId: string, + dependentLoginId: string, + selectedFamily?: string, + ): Promise> => + transformResponse( + httpClient.post(apiPaths.family.impersonate, { + impersonatorUserIdOrLoginId, + dependentLoginId, + selectedFamily, + }), + ), + /** Stop impersonating a family dependent and return to the acting admin's own session. */ + stopImpersonation: ( + jwt: string, + customClaims?: Record, + refreshDuration?: number, + ): Promise> => + transformResponse( + httpClient.post(apiPaths.family.stopImpersonation, { jwt, customClaims, refreshDuration }), + ), +}); + +export default withFamily; diff --git a/lib/management/index.ts b/lib/management/index.ts index 59a99cf27..c39df1ae0 100644 --- a/lib/management/index.ts +++ b/lib/management/index.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@descope/core-js-sdk'; import withUser from './user'; import withProject from './project'; import withTenant from './tenant'; +import withFamily from './family'; import withJWT from './jwt'; import withPermission from './permission'; import withRole from './role'; @@ -32,6 +33,7 @@ const withManagement = (client: HttpClient, fgaConfig?: FGAConfig) => ({ project: withProject(client), accessKey: withAccessKey(client), tenant: withTenant(client), + family: withFamily(client), ssoApplication: withSSOApplication(client), inboundApplication: withInboundApplication(client), outboundApplication: withOutboundApplication(client), diff --git a/lib/management/paths.ts b/lib/management/paths.ts index 10ed1992b..9916660e2 100644 --- a/lib/management/paths.ts +++ b/lib/management/paths.ts @@ -31,6 +31,8 @@ export default { removeSSOApps: '/v1/mgmt/user/update/ssoapp/remove', addTenant: '/v1/mgmt/user/update/tenant/add', removeTenant: '/v1/mgmt/user/update/tenant/remove', + addFamilies: '/v1/mgmt/user/update/family/add', + removeFamilies: '/v1/mgmt/user/update/family/remove', setPassword: '/v1/mgmt/user/password/set', // Deprecated setTemporaryPassword: '/v1/mgmt/user/password/set/temporary', setActivePassword: '/v1/mgmt/user/password/set/active', @@ -91,6 +93,18 @@ export default { generateSSOConfigurationLink: '/v2/mgmt/tenant/adminlinks/sso/generate', revokeSSOConfigurationLink: '/v1/mgmt/tenant/adminlinks/sso/revoke', }, + family: { + create: '/v1/mgmt/family/create', + update: '/v1/mgmt/family/update', + delete: '/v1/mgmt/family/delete', + search: '/v1/mgmt/family/search', + dependent: { + create: '/v1/mgmt/family/dependent/create', + delete: '/v1/mgmt/family/dependent/delete', + }, + impersonate: '/v1/mgmt/family/impersonate', + stopImpersonation: '/v1/mgmt/family/impersonate/stop', + }, ssoApplication: { oidcCreate: '/v1/mgmt/sso/idp/app/oidc/create', samlCreate: '/v1/mgmt/sso/idp/app/saml/create', diff --git a/lib/management/types.ts b/lib/management/types.ts index b3785a90e..dda49bd7e 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -295,6 +295,56 @@ export type TenantSettings = { ssoSetupSuiteSettings?: SSOSetupSuiteSettings; }; +/** + * Represents a family association for a user. The familyId is required to denote which family + * the user belongs to. roleNames is an optional list of the user's roles within that family, and + * familyScopedAttributes is an optional map of the user's custom attribute values scoped to that family. + */ +export type AssociatedFamily = { + familyId: string; + roleNames?: string[]; + familyScopedAttributes?: Record; +}; + +/** Represents a family in a project. A family groups a set of users (e.g. a guardian and their + * dependents) that can share access and family-scoped custom attributes. + */ +export type Family = { + id: string; + name: string; + customAttributes?: Record; + disabled?: boolean; + photo?: string; + createdTime: number; +}; + +/** Options for searching families */ +export type SearchFamiliesOptions = { + familyIds?: string[]; + freeText?: string; + familyNames?: string[]; + page?: number; + size?: number; + customAttributes?: Record; +}; + +/** Options for creating a dependent (shadow profile) user in a family */ +export type CreateFamilyDependentOptions = { + /** When missing, the login ID is derived from name; email/phone are never used as the login ID + * since they are not unique - a dependent may share them with their guardian. */ + loginId?: string; + name?: string; + email?: string; + phone?: string; + givenName?: string; + middleName?: string; + familyName?: string; + picture?: string; + customAttributes?: Record; + /** Per-family custom attribute values (familyId -> { attrName -> value }) */ + familyScopedAttributes?: Record>; +}; + /** Represents password settings of a tenant in a project. It has the password policy details. */ export type PasswordSettings = { enabled: boolean; @@ -517,6 +567,7 @@ export type User = { verifiedPhone?: boolean; test?: boolean; additionalLoginIds?: string[]; + familyAssociations?: AssociatedFamily[]; password?: string; // a cleartext password to set for the user hashedPassword?: UserPasswordHashed; // a prehashed password to set for the user seed?: string; // a TOTP seed to set for the user in case of batch invite @@ -1132,11 +1183,18 @@ export interface UserOptions { familyName?: string; additionalLoginIds?: string[]; ssoAppIds?: string[]; + familyAssociations?: AssociatedFamily[]; } export type MgmtUserOptions = Omit< UserOptions, - 'roles' | 'userTenants' | 'customAttributes' | 'picture' | 'additionalLoginIds' | 'displayName' + | 'roles' + | 'userTenants' + | 'customAttributes' + | 'picture' + | 'additionalLoginIds' + | 'displayName' + | 'familyAssociations' > & { name?: string; }; diff --git a/lib/management/user.test.ts b/lib/management/user.test.ts index 21d955a90..3f66c8b37 100644 --- a/lib/management/user.test.ts +++ b/lib/management/user.test.ts @@ -164,6 +164,7 @@ describe('Management User', () => { roles: ['r1', 'r2'], customAttributes: { a: 'a', b: 1, c: true }, additionalLoginIds: ['id-1', 'id-2'], + familyAssociations: [{ familyId: 'f1', roleNames: ['r1'] }], }); expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.create, { @@ -172,6 +173,7 @@ describe('Management User', () => { roleNames: ['r1', 'r2'], customAttributes: { a: 'a', b: 1, c: true }, additionalLoginIds: ['id-1', 'id-2'], + familyAssociations: [{ familyId: 'f1', roleNames: ['r1'] }], }); expect(resp).toEqual({ @@ -784,6 +786,7 @@ describe('Management User', () => { scim: true, ssoAppIds: ['sso1', 'sso2'], status: 'invited', + familyAssociations: [{ familyId: 'f1', roleNames: ['r1'] }], }); expect(mockHttpClient.patch).toHaveBeenCalledWith(apiPaths.user.patch, { @@ -797,6 +800,7 @@ describe('Management User', () => { ssoAppIds: ['sso1', 'sso2'], scim: true, status: 'invited', + familyAssociations: [{ familyId: 'f1', roleNames: ['r1'] }], }); }); }); @@ -1353,6 +1357,29 @@ describe('Management User', () => { roles: undefined, }); }); + + it('should pass familyIds and dependent filters', async () => { + const httpResponse = { + ok: true, + json: () => mockMgmtUsersResponse, + clone: () => ({ + json: () => Promise.resolve(mockMgmtUsersResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + await management.user.search({ + familyIds: ['f1'], + dependent: true, + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.search, { + familyIds: ['f1'], + dependent: true, + roleNames: undefined, + roles: undefined, + }); + }); }); describe('getProviderToken', () => { @@ -1900,6 +1927,66 @@ describe('Management User', () => { }); }); + describe('addFamilies', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => mockMgmtUserResponse, + clone: () => ({ + json: () => Promise.resolve(mockMgmtUserResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.user.addFamilies('lid', [ + { familyId: 'f1', roleNames: ['role1'], familyScopedAttributes: { customAttr: 'value' } }, + ]); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.addFamilies, { + loginId: 'lid', + familyAssociations: [ + { familyId: 'f1', roleNames: ['role1'], familyScopedAttributes: { customAttr: 'value' } }, + ], + }); + + expect(resp).toEqual({ + code: 200, + data: mockUserResponse, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('removeFamilies', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => mockMgmtUserResponse, + clone: () => ({ + json: () => Promise.resolve(mockMgmtUserResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.user.removeFamilies('lid', ['f1']); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.user.removeFamilies, { + loginId: 'lid', + familyIds: ['f1'], + }); + + expect(resp).toEqual({ + code: 200, + data: mockUserResponse, + ok: true, + response: httpResponse, + }); + }); + }); + describe('setTenantRoles', () => { it('should send the correct request and receive correct response', async () => { const httpResponse = { diff --git a/lib/management/user.ts b/lib/management/user.ts index 4d9b7c262..7f5cb8add 100644 --- a/lib/management/user.ts +++ b/lib/management/user.ts @@ -9,6 +9,7 @@ import { import { ProviderTokenResponse, AssociatedTenant, + AssociatedFamily, GenerateEnchantedLinkForTestResponse, GenerateMagicLinkForTestResponse, GenerateOTPForTestResponse, @@ -65,6 +66,8 @@ type SearchRequest = { tenantRoleNames?: Record; // Search users based on tenants and role names verifiedEmail?: boolean; // Filter by verified email status verifiedPhone?: boolean; // Filter by verified phone status + familyIds?: string[]; // Only return users that are members of at least one of these families + dependent?: boolean; // Filter by whether the user is a family dependent (no login credentials of their own) }; type SingleUserResponse = { @@ -459,6 +462,9 @@ const withUser = (httpClient: HttpClient) => { if (options.additionalIdentifiers !== undefined) { body.additionalIdentifiers = options.additionalIdentifiers; } + if (options.familyAssociations !== undefined) { + body.familyAssociations = options.familyAssociations; + } return body; } @@ -821,6 +827,31 @@ const withUser = (httpClient: HttpClient) => { httpClient.post(apiPaths.user.removeTenant, { loginId: loginIdOrUserId, tenantId }), (data) => data.user, ), + /** + * Add a user to one or more families. Each entry may also set the user's roles and + * family-scoped attributes for that family in the same call; omitting roleNames or + * familyScopedAttributes on a family the user already belongs to leaves them unchanged. + */ + addFamilies: ( + loginIdOrUserId: string, + familyAssociations: AssociatedFamily[], + ): Promise> => + transformResponse( + httpClient.post(apiPaths.user.addFamilies, { + loginId: loginIdOrUserId, + familyAssociations, + }), + (data) => data.user, + ), + /** Remove a user from one or more families. */ + removeFamilies: ( + loginIdOrUserId: string, + familyIds: string[], + ): Promise> => + transformResponse( + httpClient.post(apiPaths.user.removeFamilies, { loginId: loginIdOrUserId, familyIds }), + (data) => data.user, + ), setTenantRoles: ( loginIdOrUserId: string, tenantId: string, @@ -1287,6 +1318,7 @@ export interface PatchUserOptions { scim?: boolean; status?: UserStatus; additionalIdentifiers?: string[]; + familyAssociations?: AssociatedFamily[]; } /** User options for batch patch operations, identifying the user by loginIdOrUserId or loginId */