diff --git a/.changeset/directory-sync-role-mapping.md b/.changeset/directory-sync-role-mapping.md new file mode 100644 index 00000000000..9ba10948b10 --- /dev/null +++ b/.changeset/directory-sync-role-mapping.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/localizations': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add a role mapping step to the self-serve Directory Sync setup in ``. Organization admins can now assign an organization role to each directory group their identity provider pushes, set which group wins when a member belongs to several, and turn role syncing on or off. Members in no mapped group keep the organization's default role. diff --git a/packages/clerk-js/src/core/resources/DirectorySync.ts b/packages/clerk-js/src/core/resources/DirectorySync.ts index 597a0c885c7..09235670cf2 100644 --- a/packages/clerk-js/src/core/resources/DirectorySync.ts +++ b/packages/clerk-js/src/core/resources/DirectorySync.ts @@ -2,6 +2,11 @@ import type { ClerkPaginatedResponse, DeletedObjectJSON, DeletedObjectResource, + DirectorySyncGroupRoleMappingJSON, + DirectorySyncGroupRoleMappingsJSON, + DirectorySyncGroupRoleMappingsResource, + DirectorySyncGroupsPage, + DirectorySyncGroupsPageJSON, DirectorySyncJSON, DirectorySyncJSONSnapshot, DirectorySyncProvider, @@ -10,7 +15,9 @@ import type { DirectorySyncStatusResource, DirectorySyncUserJSON, DirectorySyncUserResource, + GetDirectorySyncGroupsParams, GetDirectorySyncUsersParams, + ReplaceDirectorySyncGroupRoleMappingsParams, SetDirectorySyncCredentialsParams, UpdateDirectorySyncParams, } from '@clerk/shared/types'; @@ -19,6 +26,7 @@ import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffset import { unixEpochToDate } from '../../utils/date'; import { BaseResource } from './Base'; import { DeletedObject } from './DeletedObject'; +import { Role } from './Role'; export class DirectorySync extends BaseResource implements DirectorySyncResource { id!: string; @@ -50,6 +58,9 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource if (params.enabled !== undefined) { body.enabled = params.enabled; } + if (params.groupRoleMappingEnabled !== undefined) { + body.group_role_mapping_enabled = params.groupRoleMappingEnabled; + } if (params.attributeMapping !== undefined) { body.attribute_mapping = JSON.stringify(params.attributeMapping); } @@ -125,6 +136,57 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource }; }; + getGroups = async (params?: GetDirectorySyncGroupsParams): Promise => { + const search: Record = {}; + if (params?.limit !== undefined) { + search.limit = String(params.limit); + } + if (params?.startingAfter) { + search.starting_after = params.startingAfter; + } + + const res = await BaseResource._fetch({ + path: `${this.directoryPath}/groups`, + method: 'GET', + search, + }); + + const payload = res?.response as unknown as DirectorySyncGroupsPageJSON | undefined; + + return { + data: (payload?.data ?? []).map(group => ({ + id: group.id, + displayName: group.display_name, + updatedAt: group.updated_at ? unixEpochToDate(group.updated_at) : null, + })), + startingAfter: payload?.cursor?.starting_after ?? null, + hasNextPage: payload?.cursor?.has_next_page ?? false, + }; + }; + + getGroupRoleMappings = async (): Promise => { + const res = await BaseResource._fetch({ + path: `${this.directoryPath}/group_role_mappings`, + method: 'GET', + }); + + return toGroupRoleMappings(res?.response as unknown as DirectorySyncGroupRoleMappingsJSON | undefined); + }; + + replaceGroupRoleMappings = async ( + params: ReplaceDirectorySyncGroupRoleMappingsParams, + ): Promise => { + const res = await BaseResource._fetch({ + path: `${this.directoryPath}/group_role_mappings`, + method: 'PUT', + body: { + mappings: JSON.stringify(params.mappings.map(m => ({ directory_group_id: m.directoryGroupId, role: m.role }))), + } as any, + }); + + return toGroupRoleMappings(res?.response as unknown as DirectorySyncGroupRoleMappingsJSON | undefined); + }; + getUsers = async ( params?: GetDirectorySyncUsersParams, ): Promise> => { @@ -183,6 +245,21 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource } } +const toGroupRoleMapping = (json: DirectorySyncGroupRoleMappingJSON) => ({ + id: json.id, + directoryGroupId: json.directory_group_id, + directoryGroupDisplayName: json.directory_group_display_name, + role: json.role ? new Role(json.role) : null, + precedence: json.precedence, +}); + +const toGroupRoleMappings = ( + json: DirectorySyncGroupRoleMappingsJSON | undefined, +): DirectorySyncGroupRoleMappingsResource => ({ + data: (json?.data ?? []).map(toGroupRoleMapping), + defaultRole: json?.default_role ? new Role(json.default_role) : null, +}); + export class DirectorySyncUser extends BaseResource implements DirectorySyncUserResource { id!: string; userId!: string; diff --git a/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts b/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts index 81b451f1514..dd4f3f42f13 100644 --- a/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts @@ -1,4 +1,4 @@ -import type { DirectorySyncJSON } from '@clerk/shared/types'; +import type { DirectorySyncJSON, RoleJSON } from '@clerk/shared/types'; import { describe, expect, it, vi } from 'vitest'; import { BaseResource, DirectorySync } from '../internal'; @@ -20,6 +20,17 @@ const directoryJSON: DirectorySyncJSON = { updated_at: 1700000000000, }; +const roleJSON = (key: string, name: string): RoleJSON => ({ + object: 'role', + id: `role_${key}`, + key, + name, + description: '', + permissions: [], + created_at: 1700000000000, + updated_at: 1700000000000, +}); + function createDirectorySync(): DirectorySync { return new DirectorySync(directoryJSON, ORG_ID); } @@ -258,4 +269,105 @@ describe('DirectorySync', () => { expect(result.data[0].identifier).toBe('ada@example.com'); expect(result.data[0].active).toBe(true); }); + + it('turns group role mapping on through update', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, group_role_mapping_enabled: true } })); + + const result = await createDirectorySync().update({ groupRoleMappingEnabled: true }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: DIRECTORY_PATH, + body: { group_role_mapping_enabled: true }, + }); + expect(result.groupRoleMappingEnabled).toBe(true); + }); + + it('lists directory groups with cursor pagination', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue( + Promise.resolve({ + response: { + data: [{ object: 'directory_group', id: 'dirgrp_1', display_name: 'Engineering', updated_at: 1700000000000 }], + cursor: { starting_after: 'dirgrp_1', ending_before: null, has_next_page: true }, + }, + }), + ); + + const page = await createDirectorySync().getGroups({ limit: 1, startingAfter: 'dirgrp_0' }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'GET', + path: `${DIRECTORY_PATH}/groups`, + search: { limit: '1', starting_after: 'dirgrp_0' }, + }); + expect(page.data).toEqual([{ id: 'dirgrp_1', displayName: 'Engineering', updatedAt: new Date(1700000000000) }]); + expect(page.startingAfter).toBe('dirgrp_1'); + expect(page.hasNextPage).toBe(true); + }); + + const mappingsResponse = { + data: [ + { + object: 'directory_group_role_mapping', + id: 'dgrm_1', + directory_id: 'scimdir_1', + directory_group_id: 'dirgrp_1', + directory_group_display_name: 'Engineering', + role: roleJSON('org:admin', 'Admin'), + precedence: 1, + created_at: 1700000000000, + updated_at: 1700000000000, + }, + ], + total_count: 1, + default_role: roleJSON('org:member', 'Member'), + }; + + it('reads the group role mappings and the default role', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: mappingsResponse })); + + const result = await createDirectorySync().getGroupRoleMappings(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'GET', path: `${DIRECTORY_PATH}/group_role_mappings` }); + expect(result.data).toHaveLength(1); + expect(result.data[0]).toMatchObject({ + directoryGroupId: 'dirgrp_1', + directoryGroupDisplayName: 'Engineering', + precedence: 1, + role: { key: 'org:admin', name: 'Admin' }, + }); + expect(result.defaultRole).toMatchObject({ key: 'org:member', name: 'Member' }); + }); + + it('replaces the group role mappings, serializing them as JSON in priority order', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: mappingsResponse })); + + await createDirectorySync().replaceGroupRoleMappings({ + mappings: [ + { directoryGroupId: 'dirgrp_2', role: 'org:member' }, + { directoryGroupId: 'dirgrp_1', role: 'org:admin' }, + ], + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'PUT', + path: `${DIRECTORY_PATH}/group_role_mappings`, + body: { + mappings: JSON.stringify([ + { directory_group_id: 'dirgrp_2', role: 'org:member' }, + { directory_group_id: 'dirgrp_1', role: 'org:admin' }, + ]), + }, + }); + }); }); diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index 0665f4b68d0..3b25dc8ea9e 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -302,9 +302,41 @@ export const arSA: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index e4ad0e6c004..635acfdab16 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -302,9 +302,41 @@ export const beBY: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index 717ee166f6e..6f0ddc36934 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -303,9 +303,41 @@ export const bgBG: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index 022665b1f17..fb21697d340 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -308,9 +308,41 @@ export const bnIN: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index 6fecadbf40b..8aef41818a9 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -309,9 +309,41 @@ export const caES: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index 2050a63cde1..a4a0b36f8fc 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -306,9 +306,41 @@ export const csCZ: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 863123bccb2..04666c33d94 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -302,9 +302,41 @@ export const daDK: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index 3dae5675b44..da33bf85efb 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -308,9 +308,41 @@ export const deDE: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index cbaa83a14eb..a7603a71d35 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -302,9 +302,41 @@ export const elGR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index 8cf3fa19f17..a42632f498c 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -302,9 +302,41 @@ export const enGB: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 34ba72cb85c..631f94934e0 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -304,9 +304,44 @@ export const enUS: LocalizationResource = { google: 'Google Workspace', okta: 'Okta Workforce', }, + roleMappingStep: { + actionHint__reorder: 'Drag to reorder, or use the arrow keys', + actionLabel__reorder: 'Reorder {{group}} mapping', + columns: { + directoryGroup: 'Directory group', + priority: 'Priority', + role: 'Role', + }, + disableDialog: { + cancelButton: 'Cancel', + confirmButton: 'Disable role sync', + subtitle: + "Members keep their current roles, but new members won't get a role from their directory group. Your mappings are saved, and turning role sync back on reapplies them to every member, overwriting any manual role changes.", + title: 'Disabling role sync', + }, + empty__noGroups: { + subtitle: 'Push groups from {{provider}} and they will appear here, ready to map.', + title: 'No groups provisioned yet', + }, + enableDialog: { + cancelButton: 'Cancel', + confirmButton: 'Enable role sync', + subtitle: + "Members' organization roles will be overwritten by the role mapped to their directory group, including roles that were assigned manually.", + title: 'Enabling role sync', + }, + error__loadMappings: 'Could not load role mappings', + everyoneElse: 'Everyone else', + formFieldLabel__syncRoles: 'Sync roles', + roleOption__unassigned: 'Unassigned', + subtitle: + 'Members get a role from the directory group they belong to. Members belonging to multiple groups are assigned the highest priority role.', + title: 'Role mapping', + }, stepper: { attributes: 'Attributes', configure: 'Configure', + roles: 'Roles', test: 'Test', }, testStep: { diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index 881651ad605..fc30a90a668 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -302,9 +302,41 @@ export const esCR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 352b491767c..5f40dc73d40 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -308,9 +308,41 @@ export const esES: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index fb8f136a774..03498878e39 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -303,9 +303,41 @@ export const esMX: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index 4bc80046ddc..d832f97de4a 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -302,9 +302,41 @@ export const esUY: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 1cec93c532a..00992af9fcb 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -307,9 +307,41 @@ export const faIR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index af968a9348d..03af3ef6cc7 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -308,9 +308,41 @@ export const fiFI: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index f10b2f0e17e..76ee2a1559e 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -310,9 +310,41 @@ export const frFR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index 9238c575468..179284b7301 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -302,9 +302,41 @@ export const heIL: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index 50faad7f25b..6175fb4cbf9 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -308,9 +308,41 @@ export const hiIN: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index d4f315fc89c..72cc5ecb1e2 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -309,9 +309,41 @@ export const hrHR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 87f03437062..688d4b49b69 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -309,9 +309,41 @@ export const huHU: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index 4a56e4e5711..4559485892f 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -302,9 +302,41 @@ export const idID: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index 78dc41a170e..9583bb296a6 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -308,9 +308,41 @@ export const isIS: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index bb419bfd479..65bcac55361 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -308,9 +308,41 @@ export const itIT: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index 4f1fe4d90ce..6f3414c7b85 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -309,9 +309,41 @@ export const jaJP: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index f669637237f..e3864ebc49f 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -302,9 +302,41 @@ export const kkKZ: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 88e554eaefa..729da35194f 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -306,9 +306,41 @@ export const koKR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 6aa4840b5a8..2ae6e37018f 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -302,9 +302,41 @@ export const mnMN: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 7c53a9ae9c3..6a944c1a38c 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -310,9 +310,41 @@ export const msMY: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index c31ca1ffa75..a3ae72c1fee 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -309,9 +309,41 @@ export const nbNO: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 69a5f136b18..645b79e8ad2 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -302,9 +302,41 @@ export const nlBE: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index 27da0499ce9..7623193b103 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -302,9 +302,41 @@ export const nlNL: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 04af979284e..a2870dccb37 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -302,9 +302,41 @@ export const plPL: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 99b368031c2..6e0873a4d11 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -308,9 +308,41 @@ export const ptBR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index 48d6a572489..ee33333de49 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -310,9 +310,41 @@ export const ptPT: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index be1e270ce09..ab90bea2337 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -308,9 +308,41 @@ export const roRO: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 4403998a770..87f93a8ba9d 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -302,9 +302,41 @@ export const ruRU: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index c73b35e563c..9ef966b6160 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -302,9 +302,41 @@ export const skSK: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index e43def61292..5524a84bdde 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -302,9 +302,41 @@ export const srRS: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index 31dfc362a9e..b802583bccd 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -302,9 +302,41 @@ export const svSE: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index 71ba727aadc..c27248bc3d1 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -310,9 +310,41 @@ export const taIN: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index e5bf22ca4cd..2dd8f332c4f 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -309,9 +309,41 @@ export const teIN: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 25185bfbb26..8bda70c167e 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -306,9 +306,41 @@ export const thTH: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index addbef60fef..a52cf945581 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -302,9 +302,41 @@ export const trTR: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index f714296df0c..11aafe6430e 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -302,9 +302,41 @@ export const ukUA: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index f4af25fadb9..12d4f7271c3 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -308,9 +308,41 @@ export const viVN: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 429fba2d4d1..c7a10e3e8da 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -302,9 +302,41 @@ export const zhCN: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 0a59569f066..02959f97a62 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -305,9 +305,41 @@ export const zhTW: LocalizationResource = { google: undefined, okta: undefined, }, + roleMappingStep: { + actionHint__reorder: undefined, + actionLabel__reorder: undefined, + columns: { + directoryGroup: undefined, + priority: undefined, + role: undefined, + }, + disableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + empty__noGroups: { + subtitle: undefined, + title: undefined, + }, + enableDialog: { + cancelButton: undefined, + confirmButton: undefined, + subtitle: undefined, + title: undefined, + }, + error__loadMappings: undefined, + everyoneElse: undefined, + formFieldLabel__syncRoles: undefined, + roleOption__unassigned: undefined, + subtitle: undefined, + title: undefined, + }, stepper: { attributes: undefined, configure: undefined, + roles: undefined, test: undefined, }, testStep: { diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index abe5b258c87..16adaa48d8c 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -60,6 +60,12 @@ export type { UseOrganizationDirectorySyncUsersReturn, } from './useOrganizationDirectorySyncUsers'; export { __internal_useOrganizationDirectorySyncStatus } from './useOrganizationDirectorySyncStatus'; +export { __internal_useOrganizationDirectorySyncGroupRoleMappings } from './useOrganizationDirectorySyncGroupRoleMappings'; +export type { + DirectorySyncGroupRoleMappingsData, + UseOrganizationDirectorySyncGroupRoleMappingsParams, + UseOrganizationDirectorySyncGroupRoleMappingsReturn, +} from './useOrganizationDirectorySyncGroupRoleMappings'; export type { UseOrganizationDirectorySyncStatusParams, UseOrganizationDirectorySyncStatusReturn, diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts index 9f85edf1910..486b2a1b057 100644 --- a/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySync.shared.ts @@ -79,3 +79,28 @@ export function useOrganizationDirectorySyncStatusCacheKeys(params: { }); }, [organizationId, enterpriseConnectionId, directoryId]); } + +/** + * @internal + */ +export function useOrganizationDirectorySyncGroupRoleMappingsCacheKeys(params: { + organizationId: string | null; + enterpriseConnectionId: string | null; + directoryId: string | null; +}) { + const { organizationId, enterpriseConnectionId, directoryId } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_DIRECTORY_SYNC_GROUP_ROLE_MAPPINGS_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + directoryId: directoryId ?? null, + }, + untracked: { + args: {}, + }, + }); + }, [organizationId, enterpriseConnectionId, directoryId]); +} diff --git a/packages/shared/src/react/hooks/useOrganizationDirectorySyncGroupRoleMappings.tsx b/packages/shared/src/react/hooks/useOrganizationDirectorySyncGroupRoleMappings.tsx new file mode 100644 index 00000000000..b50cc8f9d84 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationDirectorySyncGroupRoleMappings.tsx @@ -0,0 +1,134 @@ +import { useCallback } from 'react'; + +import type { + DirectorySyncGroupResource, + DirectorySyncGroupRoleMappingResource, + DirectorySyncResource, + ReplaceDirectorySyncGroupRoleMappingsParams, + RoleResource, +} from '../../types'; +import { useClerkInstanceContext } from '../contexts'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationDirectorySyncGroupRoleMappingsCacheKeys } from './useOrganizationDirectorySync.shared'; + +const GROUPS_PAGE_SIZE = 500; + +export type UseOrganizationDirectorySyncGroupRoleMappingsParams = { + /** The directory to read, e.g. `data` from `useOrganizationDirectorySync`. Nothing is fetched while `null` or `undefined`. */ + directory: DirectorySyncResource | null | undefined; + /** + * If `false`, nothing is fetched. + * + * @default true + */ + enabled?: boolean; +}; + +export type DirectorySyncGroupRoleMappingsData = { + /** Every group the identity provider has pushed. */ + groups: DirectorySyncGroupResource[]; + /** The mappings in priority order. */ + mappings: DirectorySyncGroupRoleMappingResource[]; + /** The role members in no mapped group receive. */ + defaultRole: RoleResource | null; +}; + +export type UseOrganizationDirectorySyncGroupRoleMappingsReturn = { + /** `undefined` while loading and while the hook is disabled. */ + data: DirectorySyncGroupRoleMappingsData | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + /** Replaces every mapping and caches the result. */ + replaceGroupRoleMappings: ( + params: ReplaceDirectorySyncGroupRoleMappingsParams, + ) => Promise; + revalidate: () => Promise; +}; + +const fetchAllGroups = async (directory: DirectorySyncResource): Promise => { + const groups: DirectorySyncGroupResource[] = []; + let startingAfter: string | undefined; + do { + const page = await directory.getGroups({ limit: GROUPS_PAGE_SIZE, startingAfter }); + groups.push(...page.data); + startingAfter = page.hasNextPage && page.startingAfter ? page.startingAfter : undefined; + } while (startingAfter); + return groups; +}; + +/** + * The groups a Directory Sync directory has received, its group role mappings, and the organization's + * default role, loaded together because the mapping editor needs all three. + * + * @internal + */ +function useOrganizationDirectorySyncGroupRoleMappings( + params: UseOrganizationDirectorySyncGroupRoleMappingsParams, +): UseOrganizationDirectorySyncGroupRoleMappingsReturn { + const { directory, enabled = true } = params; + + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + + const { queryKey, invalidationKey, stableKey, authenticated } = + useOrganizationDirectorySyncGroupRoleMappingsCacheKeys({ + organizationId: organization?.id ?? null, + enterpriseConnectionId: directory?.enterpriseConnectionId ?? null, + directoryId: directory?.id ?? null, + }); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const belongsToActiveOrganization = Boolean(organization) && directory?.organizationId === organization?.id; + const queryEnabled = enabled && clerk.loaded && belongsToActiveOrganization && Boolean(directory); + + const query = useClerkQuery({ + queryKey, + queryFn: async (): Promise => { + if (!directory) { + throw new Error('directory is required to fetch group role mappings'); + } + const [groups, mappings] = await Promise.all([fetchAllGroups(directory), directory.getGroupRoleMappings()]); + return { groups, mappings: mappings.data, defaultRole: mappings.defaultRole }; + }, + enabled: queryEnabled, + }); + + const revalidate = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: invalidationKey }); + }, [queryClient, invalidationKey]); + + const replaceGroupRoleMappings = useCallback( + async (replaceParams: ReplaceDirectorySyncGroupRoleMappingsParams) => { + if (!directory) { + return undefined; + } + const replaced = await directory.replaceGroupRoleMappings(replaceParams); + queryClient.setQueryData(queryKey, previous => + previous ? { ...previous, mappings: replaced.data, defaultRole: replaced.defaultRole } : previous, + ); + return replaced.data; + }, + [directory, queryClient, queryKey], + ); + + return { + data: queryEnabled ? query.data : undefined, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + replaceGroupRoleMappings, + revalidate, + }; +} + +export { useOrganizationDirectorySyncGroupRoleMappings as __internal_useOrganizationDirectorySyncGroupRoleMappings }; diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index 0df670488f0..cb673d6fce3 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -86,6 +86,7 @@ const ORGANIZATION_DOMAINS_KEY = 'organizationDomains'; const ORGANIZATION_DIRECTORY_SYNC_KEY = 'organizationDirectorySync'; const ORGANIZATION_DIRECTORY_SYNC_USERS_KEY = 'organizationDirectorySyncUsers'; const ORGANIZATION_DIRECTORY_SYNC_STATUS_KEY = 'organizationDirectorySyncStatus'; +const ORGANIZATION_DIRECTORY_SYNC_GROUP_ROLE_MAPPINGS_KEY = 'organizationDirectorySyncGroupRoleMappings'; const ORGANIZATION_SSO_BYPASS_ALLOWLIST_KEY = 'organizationSSOBypassAllowlist'; const CREDIT_HISTORY_KEY = 'billing-credit-history'; @@ -103,6 +104,7 @@ export const INTERNAL_STABLE_KEYS = { ORGANIZATION_DIRECTORY_SYNC_KEY, ORGANIZATION_DIRECTORY_SYNC_USERS_KEY, ORGANIZATION_DIRECTORY_SYNC_STATUS_KEY, + ORGANIZATION_DIRECTORY_SYNC_GROUP_ROLE_MAPPINGS_KEY, ORGANIZATION_SSO_BYPASS_ALLOWLIST_KEY, } as const; diff --git a/packages/shared/src/types/directorySync.ts b/packages/shared/src/types/directorySync.ts index e8340764fcd..d7ac19e8f77 100644 --- a/packages/shared/src/types/directorySync.ts +++ b/packages/shared/src/types/directorySync.ts @@ -1,7 +1,8 @@ import type { DeletedObjectResource } from './deletedObject'; -import type { ClerkResourceJSON } from './json'; +import type { ClerkResourceJSON, RoleJSON } from './json'; import type { ClerkPaginatedResponse } from './pagination'; import type { ClerkResource } from './resource'; +import type { RoleResource } from './role'; /** * The SCIM provider backing a Directory Sync directory. Derived server-side @@ -107,6 +108,21 @@ export interface DirectorySyncResource extends ClerkResource { * completes. */ getSyncStatus: () => Promise; + /** + * Gets a page of the groups the identity provider has pushed into the directory. + */ + getGroups: (params?: GetDirectorySyncGroupsParams) => Promise; + /** + * Gets the group role mappings in priority order, together with the organization's default role. + */ + getGroupRoleMappings: () => Promise; + /** + * Replaces every group role mapping. The list order is the priority, and a group left out is unmapped. + * Members' roles are reassigned in the background. + */ + replaceGroupRoleMappings: ( + params: ReplaceDirectorySyncGroupRoleMappingsParams, + ) => Promise; __internal_toSnapshot: () => DirectorySyncJSONSnapshot; } @@ -148,6 +164,8 @@ export type UpdateDirectorySyncParams = { enabled?: boolean; /** Partial attribute mapping to merge into the stored one; `null` values remove keys. */ attributeMapping?: Record; + /** Turns group role mapping on (`true`) or off (`false`). */ + groupRoleMappingEnabled?: boolean; }; export type CreateDirectorySyncParams = { @@ -193,3 +211,79 @@ export type SetDirectorySyncCredentialsParams = { /** The directory administrator the service account impersonates when reading the directory. */ subjectEmail: string; }; + +export interface DirectorySyncGroupJSON { + object: 'directory_group'; + id: string; + display_name: string; + updated_at: number; +} + +export interface DirectorySyncGroupResource { + id: string; + /** The group's name as the identity provider pushed it. */ + displayName: string; + updatedAt: Date | null; +} + +export interface DirectorySyncGroupsPageJSON { + data: DirectorySyncGroupJSON[]; + cursor: { + starting_after: string | null; + ending_before: string | null; + has_next_page: boolean; + }; +} + +export interface DirectorySyncGroupsPage { + data: DirectorySyncGroupResource[]; + /** Pass as `startingAfter` to fetch the next page; `null` on the last page. */ + startingAfter: string | null; + hasNextPage: boolean; +} + +export type GetDirectorySyncGroupsParams = { + /** At most 500. */ + limit?: number; + startingAfter?: string; +}; + +export interface DirectorySyncGroupRoleMappingJSON { + object: 'directory_group_role_mapping'; + id: string; + directory_id: string; + directory_group_id: string; + directory_group_display_name: string; + role?: RoleJSON | null; + precedence: number; + created_at: number; + updated_at: number; +} + +export interface DirectorySyncGroupRoleMappingResource { + id: string; + directoryGroupId: string; + directoryGroupDisplayName: string; + /** `null` if the mapped role no longer exists. */ + role: RoleResource | null; + /** 1 is the highest priority. */ + precedence: number; +} + +export interface DirectorySyncGroupRoleMappingsJSON { + data: DirectorySyncGroupRoleMappingJSON[]; + total_count: number; + default_role: RoleJSON | null; +} + +export interface DirectorySyncGroupRoleMappingsResource { + /** The mappings in priority order. */ + data: DirectorySyncGroupRoleMappingResource[]; + /** The role members in no mapped group receive. It cannot be changed through the directory. */ + defaultRole: RoleResource | null; +} + +export type ReplaceDirectorySyncGroupRoleMappingsParams = { + /** In priority order. `role` is an organization role key, such as `org:admin`. At most 100 entries. */ + mappings: { directoryGroupId: string; role: string }[]; +}; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 912ac54e607..c3caaafaef6 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1596,6 +1596,7 @@ export type __internal_LocalizationResource = { configure: LocalizationValue; attributes: LocalizationValue; test: LocalizationValue; + roles: LocalizationValue; }; providers: { okta: LocalizationValue; @@ -1690,6 +1691,37 @@ export type __internal_LocalizationResource = { error__loadUsers: LocalizationValue; actionLabel__complete: LocalizationValue; }; + roleMappingStep: { + title: LocalizationValue; + subtitle: LocalizationValue; + formFieldLabel__syncRoles: LocalizationValue; + columns: { + priority: LocalizationValue; + directoryGroup: LocalizationValue; + role: LocalizationValue; + }; + roleOption__unassigned: LocalizationValue; + everyoneElse: LocalizationValue; + actionLabel__reorder: LocalizationValue<'group'>; + actionHint__reorder: LocalizationValue; + empty__noGroups: { + title: LocalizationValue; + subtitle: LocalizationValue<'provider'>; + }; + error__loadMappings: LocalizationValue; + enableDialog: { + title: LocalizationValue; + subtitle: LocalizationValue; + cancelButton: LocalizationValue; + confirmButton: LocalizationValue; + }; + disableDialog: { + title: LocalizationValue; + subtitle: LocalizationValue; + cancelButton: LocalizationValue; + confirmButton: LocalizationValue; + }; + }; }; configureSSO: { connectionScopeBanner: { diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index ec22fea446c..3bfffc77315 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/ui.browser.js", "maxSize": "45KB" }, - { "path": "./dist/ui.legacy.browser.js", "maxSize": "85KB" }, + { "path": "./dist/ui.legacy.browser.js", "maxSize": "86KB" }, { "path": "./dist/ui.shared.browser.js", "maxSize": "44KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx index 50dd4efdd78..51f01e63b98 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySync.tsx @@ -36,7 +36,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { > - + diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx index 04a17a2cda2..420f12718b7 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -1,5 +1,6 @@ import { __internal_useOrganizationDirectorySync, + __internal_useOrganizationDirectorySyncGroupRoleMappings, __internal_useOrganizationEnterpriseConnections, } from '@clerk/shared/react'; import type { @@ -7,12 +8,38 @@ import type { DirectorySyncResource, EnterpriseConnectionResource, SetDirectorySyncCredentialsParams, + UpdateDirectorySyncParams, } from '@clerk/shared/types'; import React, { type PropsWithChildren } from 'react'; +import { useFetchRoles } from '../../hooks/useFetchRoles'; import { sortEnterpriseConnections } from '../ConfigureSSO/domain/organizationEnterpriseConnection'; import type { DirectorySyncProviderMeta } from './providerMeta'; import { DIRECTORY_SYNC_PROVIDERS, directorySyncProviderForConnection } from './providerMeta'; +import type { GroupRoleMapping, RoleOption, UnmappedGroup } from './roleMapping'; +import { moveItem, NO_ROLE_KEY } from './roleMapping'; + +/** + * Group → role mapping edits for the wizard's role mapping step. Edits stay + * local until `save`; `mappings` is ordered by priority (index 0 wins). + */ +export interface RoleMappingView { + isLoading: boolean; + error: Error | null; + enabled: boolean; + setEnabled: (enabled: boolean) => void; + roles: RoleOption[]; + mappings: GroupRoleMapping[]; + unmappedGroups: UnmappedGroup[]; + /** Binds a group to a role; a new mapping is appended at the lowest priority. `NO_ROLE_KEY` unmaps it. */ + setRole: (group: UnmappedGroup, roleKey: string) => void; + /** Reorders a mapping by position within `mappings`. */ + move: (from: number, to: number) => void; + /** The role members in no mapped group receive. */ + defaultRole: RoleOption | null; + /** Writes any changed mappings and the enabled flag. */ + save: () => Promise; +} /** * Shared state for the ConfigureDirectorySync wizard, persisted across steps. @@ -39,6 +66,8 @@ export interface ConfigureDirectorySyncData { setCredentials: (params: SetDirectorySyncCredentialsParams) => Promise; /** Starts a sync for a pull directory. */ syncDirectory: () => Promise; + roleMapping: RoleMappingView; + contentRef?: React.RefObject; onExit?: () => void; } @@ -46,6 +75,7 @@ const ConfigureDirectorySyncContext = React.createContext; onExit?: () => void; }>; @@ -55,6 +85,7 @@ type RevealedToken = { }; export const ConfigureDirectorySyncProvider = ({ + contentRef, onExit, children, }: ConfigureDirectorySyncProviderProps): JSX.Element => { @@ -105,6 +136,8 @@ export const ConfigureDirectorySyncProvider = ({ const provider = directory?.provider ?? (connection ? directorySyncProviderForConnection(connection.provider) : undefined); + const roleMapping = useRoleMapping(directory, updateDirectorySync); + const value: ConfigureDirectorySyncData = { isLoading: isLoadingConnections || (Boolean(enterpriseConnectionId) && isLoadingDirectory), connection, @@ -117,12 +150,109 @@ export const ConfigureDirectorySyncProvider = ({ setDirectoryEnabled, setCredentials: setDirectorySyncCredentials, syncDirectory, + roleMapping, + contentRef, onExit, }; return {children}; }; +const sameMappings = (a: GroupRoleMapping[], b: GroupRoleMapping[]): boolean => + a.length === b.length && a.every((m, i) => m.groupId === b[i].groupId && m.roleKey === b[i].roleKey); + +const useRoleMapping = ( + directory: DirectorySyncResource | null | undefined, + updateDirectorySync: (params: UpdateDirectorySyncParams) => Promise, +): RoleMappingView => { + const { + data, + error, + isLoading: isLoadingMappings, + replaceGroupRoleMappings, + } = __internal_useOrganizationDirectorySyncGroupRoleMappings({ directory }); + const { options: roleOptions, isLoading: isLoadingRoles } = useFetchRoles(); + + const savedMappings = React.useMemo( + () => + (data?.mappings ?? []).flatMap(m => + m.role ? [{ groupId: m.directoryGroupId, groupName: m.directoryGroupDisplayName, roleKey: m.role.key }] : [], + ), + [data?.mappings], + ); + const savedEnabled = directory?.groupRoleMappingEnabled ?? false; + + const [draftMappings, setDraftMappings] = React.useState(null); + const [draftEnabled, setDraftEnabled] = React.useState(null); + const mappings = draftMappings ?? savedMappings; + const enabled = draftEnabled ?? savedEnabled; + + const unmappedGroups = React.useMemo( + () => + (data?.groups ?? []) + .filter(g => !mappings.some(m => m.groupId === g.id)) + .map(g => ({ id: g.id, name: g.displayName })) + .sort((a, b) => a.name.localeCompare(b.name)), + [data?.groups, mappings], + ); + + const setRole = React.useCallback( + (group: UnmappedGroup, roleKey: string) => { + setDraftMappings(prev => { + const current = prev ?? savedMappings; + if (roleKey === NO_ROLE_KEY) { + return current.filter(m => m.groupId !== group.id); + } + return current.some(m => m.groupId === group.id) + ? current.map(m => (m.groupId === group.id ? { ...m, roleKey } : m)) + : [...current, { groupId: group.id, groupName: group.name, roleKey }]; + }); + }, + [savedMappings], + ); + + const move = React.useCallback( + (from: number, to: number) => { + setDraftMappings(prev => moveItem(prev ?? savedMappings, from, to)); + }, + [savedMappings], + ); + + const save = React.useCallback(async () => { + if (draftMappings && !sameMappings(draftMappings, savedMappings)) { + await replaceGroupRoleMappings({ + mappings: draftMappings.map(m => ({ directoryGroupId: m.groupId, role: m.roleKey })), + }); + } + setDraftMappings(null); + if (draftEnabled !== null && draftEnabled !== savedEnabled) { + await updateDirectorySync({ groupRoleMappingEnabled: draftEnabled }); + } + setDraftEnabled(null); + }, [draftMappings, savedMappings, draftEnabled, savedEnabled, replaceGroupRoleMappings, updateDirectorySync]); + + const defaultRole = data?.defaultRole + ? { + value: data.defaultRole.key, + label: roleOptions?.find(r => r.value === data.defaultRole?.key)?.label ?? data.defaultRole.name, + } + : null; + + return { + isLoading: isLoadingMappings || Boolean(isLoadingRoles), + error, + enabled, + setEnabled: setDraftEnabled, + roles: roleOptions ?? [], + mappings, + unmappedGroups, + setRole, + move, + defaultRole, + save, + }; +}; + export const useConfigureDirectorySync = (): ConfigureDirectorySyncData => { const ctx = React.useContext(ConfigureDirectorySyncContext); if (!ctx) { diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx index 438ea05b842..61b2cf7cfa5 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncWizard.tsx @@ -10,10 +10,12 @@ import { Wizard, type WizardStepConfig } from '../ConfigureSSO/elements/Wizard'; import { ConfigureDirectorySyncProvider, useConfigureDirectorySync } from './ConfigureDirectorySyncContext'; import { AttributeMappingStep } from './steps/AttributeMappingStep'; import { ConfigureStep } from './steps/ConfigureStep'; +import { RoleMappingStep } from './steps/RoleMappingStep'; import { TestSyncStep } from './steps/TestSyncStep'; export type ConfigureDirectorySyncWizardProps = { title?: React.ReactNode; + contentRef?: React.RefObject; onExit?: () => void; }; @@ -23,7 +25,10 @@ export type ConfigureDirectorySyncWizardProps = { * organization enterprise connection and its SCIM directory. */ export const ConfigureDirectorySyncWizard = (props: ConfigureDirectorySyncWizardProps): JSX.Element => ( - + ); @@ -50,6 +55,11 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme label: localizationKeys('configureDirectorySync.stepper.test'), isReachable: () => hasSsoConnection && hasDirectory, }, + { + id: 'roles', + label: localizationKeys('configureDirectorySync.stepper.roles'), + isReachable: () => hasSsoConnection && hasDirectory, + }, ], [hasSsoConnection, hasDirectory], ); @@ -88,6 +98,14 @@ const WizardInternal = ({ title }: ConfigureDirectorySyncWizardProps): JSX.Eleme + + + + + + + + ); }; diff --git a/packages/ui/src/components/ConfigureDirectorySync/RoleSyncDialog.tsx b/packages/ui/src/components/ConfigureDirectorySync/RoleSyncDialog.tsx new file mode 100644 index 00000000000..228394da2f1 --- /dev/null +++ b/packages/ui/src/components/ConfigureDirectorySync/RoleSyncDialog.tsx @@ -0,0 +1,85 @@ +import { Button, descriptors, Flex, localizationKeys } from '@/customizables'; +import { Card } from '@/elements/Card'; +import { withCardStateProvider } from '@/elements/contexts'; +import { Header } from '@/elements/Header'; +import { Modal } from '@/elements/Modal'; + +type RoleSyncDialogProps = { + action: 'enable' | 'disable'; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + contentRef?: React.RefObject; +}; + +export const RoleSyncDialog = (props: RoleSyncDialogProps): JSX.Element | null => { + if (!props.isOpen) { + return null; + } + + return ( + ({ + alignItems: 'center', + position: 'absolute', + inset: 0, + width: 'auto', + height: 'auto', + backgroundColor: 'inherit', + backdropFilter: `blur(${t.sizes.$2})`, + })} + > + + + ); +}; + +const RoleSyncDialogContent = withCardStateProvider(({ action, onClose, onConfirm }: RoleSyncDialogProps) => { + const dialogKey = action === 'enable' ? 'enableDialog' : 'disableDialog'; + + return ( + ({ borderRadius: t.radii.$md })} + > + ({ textAlign: 'start', padding: t.sizes.$5, gap: t.space.$4 })}> + + + + + + + ({ fontSize: t.fontSizes.$md, fontVariantNumeric: 'tabular-nums' })} + > + {index + 1} + + + + + + + + roleMapping.setRole(group, key)} + triggerSx={selectTriggerSx} + /> + + + ); +}; + +const UnmappedRow = ({ group }: { group: UnmappedGroup }): JSX.Element => { + const { roleMapping } = useConfigureDirectorySync(); + const roleOptions = useGroupRoleOptions(); + + return ( + + + + + + + + roleMapping.setRole(group, key)} + triggerSx={selectTriggerSx} + /> + + + ); +}; + +const EveryoneElseRow = (): JSX.Element => { + const { roleMapping } = useConfigureDirectorySync(); + const { defaultRole } = roleMapping; + + return ( + ({ backgroundColor: t.colors.$neutralAlpha25 })} + > + + + + + + + + + {}} + isDisabled + triggerSx={selectTriggerSx} + /> + + + ); +}; + +const MappingTable = (): JSX.Element => { + const { roleMapping } = useConfigureDirectorySync(); + const { mappings, unmappedGroups } = roleMapping; + + const [dragIndex, setDragIndex] = React.useState(null); + const [overIndex, setOverIndex] = React.useState(null); + + const endDrag = () => { + setDragIndex(null); + setOverIndex(null); + }; + + return ( + ({ + flex: '0 1 auto', + minHeight: 0, + overflowY: 'auto', + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + borderRadius: t.radii.$lg, + })} + > + ({ + borderWidth: 0, + borderRadius: 0, + 'thead th': { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: t.colors.$colorBackground, + boxShadow: `inset 0 -1px 0 0 ${t.colors.$borderAlpha150}`, + }, + 'thead::after': { display: 'none' }, + tbody: { borderTopWidth: 0 }, + 'tbody > tr:first-of-type > td': { borderTopWidth: 0 }, + 'tr > th': { paddingBlock: t.space.$3, paddingInline: t.space.$4 }, + 'tr > th:first-of-type': { paddingInlineStart: t.space.$8 }, + 'tr > td': { paddingBlock: t.space.$3, verticalAlign: 'middle' }, + 'tr > td:first-of-type': { paddingInlineStart: t.space.$8, paddingInlineEnd: 0 }, + })} + > + + + + + + + + + {mappings.map((mapping, index) => ( + { + if (dragIndex !== null) { + roleMapping.move(dragIndex, to); + } + endDrag(); + }} + onDragEnd={endDrag} + /> + ))} + {unmappedGroups.map(group => ( + + ))} + + +
+ + + + + + +
+
+ ); +}; + +const EmptyGroups = (): JSX.Element => { + const { providerMeta } = useConfigureDirectorySync(); + const { t } = useLocalizations(); + const providerName = t((providerMeta ?? DIRECTORY_SYNC_PROVIDERS.custom).name); + + return ( + ({ + gap: t.space.$1, + padding: t.space.$8, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: 'dashed', + borderColor: t.colors.$borderAlpha150, + textAlign: 'center', + })} + > + ({ fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium })} + localizationKey={localizationKeys('configureDirectorySync.roleMappingStep.empty__noGroups.title')} + /> + ({ fontSize: t.fontSizes.$sm })} + localizationKey={localizationKeys('configureDirectorySync.roleMappingStep.empty__noGroups.subtitle', { + provider: providerName, + })} + /> + + ); +}; + +const RoleMappingContent = (): JSX.Element => { + const { roleMapping } = useConfigureDirectorySync(); + const { isLoading, error, mappings, unmappedGroups } = roleMapping; + + if (isLoading) { + return ( + ({ padding: t.space.$8 })} + > + + + ); + } + + if (error) { + return ( + + ); + } + + return mappings.length === 0 && unmappedGroups.length === 0 ? : ; +}; + +export const RoleMappingStep = (): JSX.Element => { + const { goPrev } = useWizard(); + const { roleMapping, contentRef, onExit } = useConfigureDirectorySync(); + const card = useCardState(); + const [pendingAction, setPendingAction] = React.useState<'enable' | 'disable' | null>(null); + + const complete = async () => { + if (card.isLoading) { + return; + } + card.setError(undefined); + card.setLoading(); + try { + await roleMapping.save(); + onExit?.(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + <> + + ({ flexShrink: 0, height: `calc(${t.fontSizes.$lg} * ${t.lineHeights.$medium})` })} + > + setPendingAction(enabled ? 'enable' : 'disable')} + /> + + + + setPendingAction(null)} + onConfirm={() => roleMapping.setEnabled(pendingAction === 'enable')} + contentRef={contentRef} + /> + + + ({ gap: t.space.$4, minHeight: 0 })} + > + {card.error && ( + + )} + + + + + + goPrev()} /> +