diff --git a/.changeset/sso-bypass-allowlist-by-role.md b/.changeset/sso-bypass-allowlist-by-role.md new file mode 100644 index 00000000000..251c46f524b --- /dev/null +++ b/.changeset/sso-bypass-allowlist-by-role.md @@ -0,0 +1,12 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +The "Add members" card on the SSO allow list page of `` now offers two ways to add people: by email address, or every member with a given role at once. Members whose email address is not served by one of the organization's enterprise connections are skipped, and the card reports how many were added and skipped before it closes. + +For custom flows, `organization.ssoBypassAllowlist` gains `addUsers({ userIds })`, which calls the new bulk endpoint in batches of 100 and returns the added entries together with the users that could not be added and why. + +New customization handles: the `organizationProfileSecuritySsoBypassEmailInput`, `organizationProfileSecuritySsoBypassRoleWarning` and `organizationProfileSecuritySsoBypassBulkResult` appearance elements. diff --git a/packages/clerk-js/src/core/resources/SSOBypassAllowlist.ts b/packages/clerk-js/src/core/resources/SSOBypassAllowlist.ts index 4addb975e7a..5d4ee065340 100644 --- a/packages/clerk-js/src/core/resources/SSOBypassAllowlist.ts +++ b/packages/clerk-js/src/core/resources/SSOBypassAllowlist.ts @@ -1,7 +1,10 @@ import type { AddSSOBypassAllowlistUserParams, + AddSSOBypassAllowlistUsersParams, DeletedObjectJSON, DeletedObjectResource, + SSOBypassAllowlistBulkCreateJSON, + SSOBypassAllowlistBulkCreateResult, SSOBypassAllowlistResource, SSOBypassAllowlistUserJSON, SSOBypassAllowlistUserResource, @@ -11,6 +14,8 @@ import { BaseResource } from './Base'; import { DeletedObject } from './DeletedObject'; import { SSOBypassAllowlistUser } from './SSOBypassAllowlistUser'; +const BULK_SIZE = 100; + export class SSOBypassAllowlist implements SSOBypassAllowlistResource { declare private readonly organization: { id: string }; @@ -45,6 +50,25 @@ export class SSOBypassAllowlist implements SSOBypassAllowlistResource { return new SSOBypassAllowlistUser(json); }; + addUsers = async (params: AddSSOBypassAllowlistUsersParams): Promise => { + const result: SSOBypassAllowlistBulkCreateResult = { data: [], errors: [] }; + + for (let start = 0; start < params.userIds.length; start += BULK_SIZE) { + const json = ( + await BaseResource._fetch({ + path: `${this.path}/bulk`, + method: 'POST', + body: { user_id: params.userIds.slice(start, start + BULK_SIZE) } as any, + }) + )?.response as unknown as SSOBypassAllowlistBulkCreateJSON; + + result.data.push(...(json?.data ?? []).map(entry => new SSOBypassAllowlistUser(entry))); + result.errors.push(...(json?.errors ?? []).map(error => ({ userId: error.user_id, code: error.code }))); + } + + return result; + }; + removeUser = async (userId: string): Promise => { const json = ( await BaseResource._fetch({ diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index 674fba28e18..a1f253af1a3 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -468,6 +468,49 @@ describe('Organization', () => { expect(entry.userId).toBe('user_1'); }); + it('adds users in chunks of 100 and merges the partial results', async () => { + const userIds = Array.from({ length: 150 }, (_, i) => `user_${i}`); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + response: { data: [entryJSON], errors: [{ user_id: 'user_5', code: 'sso_bypass_domain_not_served' }] }, + }) + .mockResolvedValueOnce({ + response: { data: [{ ...entryJSON, user_id: 'user_120' }], errors: [] }, + }); + // @ts-ignore + BaseResource._fetch = fetchMock; + + const organization = createOrganization(); + const result = await organization.ssoBypassAllowlist.addUsers({ userIds }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith(1, { + method: 'POST', + path: `${ALLOWLIST_PATH}/bulk`, + body: { user_id: userIds.slice(0, 100) }, + }); + expect(fetchMock).toHaveBeenNthCalledWith(2, { + method: 'POST', + path: `${ALLOWLIST_PATH}/bulk`, + body: { user_id: userIds.slice(100) }, + }); + expect(result.data.map(entry => entry.userId)).toEqual(['user_1', 'user_120']); + expect(result.errors).toEqual([{ userId: 'user_5', code: 'sso_bypass_domain_not_served' }]); + }); + + it('sends nothing for an empty batch', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn(); + + const organization = createOrganization(); + const result = await organization.ssoBypassAllowlist.addUsers({ userIds: [] }); + + // @ts-ignore + expect(BaseResource._fetch).not.toHaveBeenCalled(); + expect(result).toEqual({ data: [], errors: [] }); + }); + it('removes a user by id', async () => { // @ts-ignore BaseResource._fetch = vi diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index bae370e4160..61bc0f887f1 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -1377,14 +1377,25 @@ export const arSA: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index fd61dffb722..60b60590afa 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -1380,14 +1380,25 @@ export const beBY: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index 8bbf53b726d..8290c926928 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -1380,14 +1380,25 @@ export const bgBG: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index 686f31ed8da..79e83710248 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -1387,14 +1387,25 @@ export const bnIN: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index 5f08487cd0c..be34becf027 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -1387,14 +1387,25 @@ export const caES: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index d5df10b95ed..4393cd0b676 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -1385,14 +1385,25 @@ export const csCZ: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 4dec7aca1c2..4a408b2237d 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -1378,14 +1378,25 @@ export const daDK: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index adce063eade..920fe2baa27 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -1393,14 +1393,25 @@ export const deDE: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 2a2afeabb01..c33fc943e26 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -1383,14 +1383,25 @@ export const elGR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index e07a96bce7e..acbf682a1de 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -1379,14 +1379,25 @@ export const enGB: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 387aa8b15f7..72eb7ab7d0b 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1416,13 +1416,24 @@ export const enUS: LocalizationResource = { action__add: 'Add', action__search: 'Search users', addForm: { - changeButton: 'Change', - memberLabel: 'Member', - memberPlaceholder: 'Search members', - noResults: 'No members found', - submitButton: 'Add', + emailPlaceholder: "Enter the member's email address", + error__alreadyAdded: 'This member is already on the allow list.', + error__memberNotFound: 'No member of this organization has that email address.', + modeLabel: 'Add by', + mode__email: 'Email', + mode__role: 'Role', + roleOption: '{{role}} ({{count}})', + roleWarning: 'This list does not sync. Members are added and removed manually.', + submitButton: 'Add members', subtitle: 'Members on this list can sign in with an email code when SSO is unavailable.', - title: 'Add member', + title: 'Add members', + }, + bulkResult: { + added: 'Added {{count}} members to the allow list.', + added__one: 'Added 1 member to the allow list.', + none: 'Everyone with that role is already on the allow list.', + skipped: '{{count}} members could not be added because their email address is not served by a connection.', + skipped__one: '1 member could not be added because their email address is not served by a connection.', }, table: { emptyState: 'No members on the allow list', @@ -1431,7 +1442,7 @@ export const enUS: LocalizationResource = { header__user: 'User', menuAction__remove: 'Remove', }, - title: 'SSO allow list', + title: 'SSO bypass', }, ssoBypassSection: { allowlistCount: '{{count}} users', diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index 3333de57e3f..61478bbbcd6 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -1383,14 +1383,25 @@ export const esCR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 5318d7322a1..3c0bf5fe07a 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -1388,14 +1388,25 @@ export const esES: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index c0895676430..c7a851a4222 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -1384,14 +1384,25 @@ export const esMX: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index 5d85d52d811..01cbee3c476 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -1382,14 +1382,25 @@ export const esUY: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 15d607bebcd..af4fe47dea0 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -1387,14 +1387,25 @@ export const faIR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 162703f38c1..e0740b4bb00 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -1388,14 +1388,25 @@ export const fiFI: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index 461e10efcb6..3345895d6be 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -1393,14 +1393,25 @@ export const frFR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index 7836eb1b6dd..787e56f8b2f 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -1374,14 +1374,25 @@ export const heIL: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index c71848b381b..c1e8cccdcdd 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -1387,14 +1387,25 @@ export const hiIN: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index d51d0ad8d93..9f45f07e2b6 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -1388,14 +1388,25 @@ export const hrHR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index be0c7d46357..2a1912208d3 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -1389,14 +1389,25 @@ export const huHU: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index 4f4434c49c0..8c4623cb5de 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -1381,14 +1381,25 @@ export const idID: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index 2eeefbeadcd..4aae420f44b 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -1388,14 +1388,25 @@ export const isIS: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index d938f226c74..9d451e6fb4f 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -1388,14 +1388,25 @@ export const itIT: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index 60c0fea1061..b5dc6f66320 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -1387,14 +1387,25 @@ export const jaJP: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index 3d1d8d96497..d617bcae4c8 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -1374,14 +1374,25 @@ export const kkKZ: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 132442c3d20..b7ff0a33fdd 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -1379,14 +1379,25 @@ export const koKR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 3f9c7b0695d..db47921739c 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -1381,14 +1381,25 @@ export const mnMN: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index f1f5657ec03..d38c9061f3b 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -1390,14 +1390,25 @@ export const msMY: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index a4151c4bb37..0e7d7866bf9 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -1389,14 +1389,25 @@ export const nbNO: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 5e1adc6c082..3a2b744456a 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -1381,14 +1381,25 @@ export const nlBE: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index 52a3b5bcc49..6f8f68a3225 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -1381,14 +1381,25 @@ export const nlNL: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index d4684783343..458fb5da2bc 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -1379,14 +1379,25 @@ export const plPL: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 64a2822da6b..69f92668b4a 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -1389,14 +1389,25 @@ export const ptBR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index 8a9b1731bd8..911469e34dc 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -1389,14 +1389,25 @@ export const ptPT: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index ebd873d5716..5ea8b33f001 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -1389,14 +1389,25 @@ export const roRO: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 96d03ce5a3f..9b15c0faf6f 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -1383,14 +1383,25 @@ export const ruRU: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 9ba2f4f156a..4bdb615347f 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -1379,14 +1379,25 @@ export const skSK: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index ee1093c3073..91ef30f575c 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -1379,14 +1379,25 @@ export const srRS: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index 5b652afb4da..b78b9fac815 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -1379,14 +1379,25 @@ export const svSE: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index e4e15915cde..01df243fe52 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -1392,14 +1392,25 @@ export const taIN: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index d9a957f8b1c..4a246b8e4ab 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -1389,14 +1389,25 @@ export const teIN: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index fb6515d892c..b026f855d60 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -1380,14 +1380,25 @@ export const thTH: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index 0e6efae4e09..1cd896acab0 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -1379,14 +1379,25 @@ export const trTR: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index c4f96eb5c38..cb69f22641c 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -1379,14 +1379,25 @@ export const ukUA: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index 8c3c23b9588..d79ccf84a9e 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -1387,14 +1387,25 @@ export const viVN: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 07fd70dd618..464b3c40867 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -1372,14 +1372,25 @@ export const zhCN: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 75018e1a8f2..c732181e23e 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -1375,14 +1375,25 @@ export const zhTW: LocalizationResource = { action__add: undefined, action__search: undefined, addForm: { - changeButton: undefined, - memberLabel: undefined, - memberPlaceholder: undefined, - noResults: undefined, + emailPlaceholder: undefined, + error__alreadyAdded: undefined, + error__memberNotFound: undefined, + modeLabel: undefined, + mode__email: undefined, + mode__role: undefined, + roleOption: undefined, + roleWarning: undefined, submitButton: undefined, subtitle: undefined, title: undefined, }, + bulkResult: { + added: undefined, + added__one: undefined, + none: undefined, + skipped: undefined, + skipped__one: undefined, + }, table: { emptyState: undefined, emptyState__search: undefined, diff --git a/packages/shared/src/react/hooks/__tests__/useOrganizationSSOBypassAllowlist.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOrganizationSSOBypassAllowlist.spec.tsx index 0b9c5c34d54..3dad722af44 100644 --- a/packages/shared/src/react/hooks/__tests__/useOrganizationSSOBypassAllowlist.spec.tsx +++ b/packages/shared/src/react/hooks/__tests__/useOrganizationSSOBypassAllowlist.spec.tsx @@ -9,6 +9,7 @@ const entry = (userId: string) => ({ id: userId, userId, publicUserData: { ident const getSpy = vi.fn(() => Promise.resolve([entry('user_1')])); const addSpy = vi.fn(() => Promise.resolve(entry('user_2'))); +const addManySpy = vi.fn(() => Promise.resolve({ data: [entry('user_2'), entry('user_3')], errors: [] })); const removeSpy = vi.fn(() => Promise.resolve({ id: 'user_1', deleted: true })); const defaultQueryClient = createMockQueryClient(); @@ -20,7 +21,7 @@ const mockClerk = createMockClerk({ session: null, organization: { id: 'org_1', - ssoBypassAllowlist: { getUsers: getSpy, addUser: addSpy, removeUser: removeSpy }, + ssoBypassAllowlist: { getUsers: getSpy, addUser: addSpy, addUsers: addManySpy, removeUser: removeSpy }, }, client: null, }, @@ -70,6 +71,17 @@ describe('useOrganizationSSOBypassAllowlist', () => { await waitFor(() => expect(getSpy).toHaveBeenCalledTimes(2)); }); + it('adds several users and refetches the list before resolving', async () => { + const { result } = renderAllowlist(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const added = await result.current.addUsers({ userIds: ['user_2', 'user_3'] }); + + expect(addManySpy).toHaveBeenCalledWith({ userIds: ['user_2', 'user_3'] }); + expect(added).toEqual({ data: [entry('user_2'), entry('user_3')], errors: [] }); + await waitFor(() => expect(getSpy).toHaveBeenCalledTimes(2)); + }); + it('removes a user and refetches the list before resolving', async () => { const { result } = renderAllowlist(); await waitFor(() => expect(result.current.isLoading).toBe(false)); diff --git a/packages/shared/src/react/hooks/useOrganizationSSOBypassAllowlist.tsx b/packages/shared/src/react/hooks/useOrganizationSSOBypassAllowlist.tsx index c8114205aee..f269e0e81ec 100644 --- a/packages/shared/src/react/hooks/useOrganizationSSOBypassAllowlist.tsx +++ b/packages/shared/src/react/hooks/useOrganizationSSOBypassAllowlist.tsx @@ -1,7 +1,12 @@ import { useCallback } from 'react'; import type { DeletedObjectResource } from '../../types/deletedObject'; -import type { AddSSOBypassAllowlistUserParams, SSOBypassAllowlistUserResource } from '../../types/ssoBypassAllowlist'; +import type { + AddSSOBypassAllowlistUserParams, + AddSSOBypassAllowlistUsersParams, + SSOBypassAllowlistBulkCreateResult, + SSOBypassAllowlistUserResource, +} from '../../types/ssoBypassAllowlist'; import { useClerkInstanceContext } from '../contexts'; import { defineKeepPreviousDataFn } from '../query/keep-previous-data'; import { useClerkQueryClient } from '../query/use-clerk-query-client'; @@ -21,6 +26,7 @@ export type UseOrganizationSSOBypassAllowlistReturn = { isLoading: boolean; isFetching: boolean; addUser: (params: AddSSOBypassAllowlistUserParams) => Promise; + addUsers: (params: AddSSOBypassAllowlistUsersParams) => Promise; removeUser: (userId: string) => Promise; revalidate: () => Promise; }; @@ -71,6 +77,15 @@ function useOrganizationSSOBypassAllowlist( [organization, revalidate], ); + const addUsers = useCallback( + async (addParams: AddSSOBypassAllowlistUsersParams) => { + const result = await organization?.ssoBypassAllowlist.addUsers(addParams); + await revalidate(); + return result; + }, + [organization, revalidate], + ); + const removeUser = useCallback( async (userId: string) => { const removed = await organization?.ssoBypassAllowlist.removeUser(userId); @@ -86,6 +101,7 @@ function useOrganizationSSOBypassAllowlist( isLoading: query.isLoading, isFetching: query.isFetching, addUser, + addUsers, removeUser, revalidate, }; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 34834095c01..5b697a69a82 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1216,11 +1216,15 @@ export type __internal_LocalizationResource = { addForm: { title: LocalizationValue; subtitle: LocalizationValue; - memberLabel: LocalizationValue; - changeButton: LocalizationValue; - memberPlaceholder: LocalizationValue; - noResults: LocalizationValue; + modeLabel: LocalizationValue; + mode__email: LocalizationValue; + mode__role: LocalizationValue; + emailPlaceholder: LocalizationValue; + roleOption: LocalizationValue<'role' | 'count'>; + roleWarning: LocalizationValue; submitButton: LocalizationValue; + error__memberNotFound: LocalizationValue; + error__alreadyAdded: LocalizationValue; }; table: { header__user: LocalizationValue; @@ -1229,6 +1233,13 @@ export type __internal_LocalizationResource = { emptyState__search: LocalizationValue; menuAction__remove: LocalizationValue; }; + bulkResult: { + added: LocalizationValue<'count'>; + added__one: LocalizationValue; + skipped: LocalizationValue<'count'>; + skipped__one: LocalizationValue; + none: LocalizationValue; + }; }; ssoSection: { title: LocalizationValue; diff --git a/packages/shared/src/types/ssoBypassAllowlist.ts b/packages/shared/src/types/ssoBypassAllowlist.ts index ecc177a140d..26e908a6bec 100644 --- a/packages/shared/src/types/ssoBypassAllowlist.ts +++ b/packages/shared/src/types/ssoBypassAllowlist.ts @@ -25,6 +25,30 @@ export type AddSSOBypassAllowlistUserParams = { userId: string; }; +export type AddSSOBypassAllowlistUsersParams = { + userIds: string[]; +}; + +export interface SSOBypassAllowlistBulkCreateErrorJSON { + user_id: string; + code: string; +} + +export interface SSOBypassAllowlistBulkCreateJSON { + data: SSOBypassAllowlistUserJSON[]; + errors: SSOBypassAllowlistBulkCreateErrorJSON[]; +} + +export interface SSOBypassAllowlistBulkCreateError { + userId: string; + code: string; +} + +export interface SSOBypassAllowlistBulkCreateResult { + data: SSOBypassAllowlistUserResource[]; + errors: SSOBypassAllowlistBulkCreateError[]; +} + export interface SSOBypassAllowlistResource { /** * Lists the members who may sign in with an email code when the organization's enterprise SSO is unavailable. @@ -36,6 +60,11 @@ export interface SSOBypassAllowlistResource { * organization's enterprise connections. */ addUser: (params: AddSSOBypassAllowlistUserParams) => Promise; + /** + * Adds several members to the allowlist, one request per 100 ids. Members who cannot be added are reported in + * `errors` with the same code `addUser` returns for them, and do not fail the batch. + */ + addUsers: (params: AddSSOBypassAllowlistUsersParams) => Promise; /** * Removes a member from the allowlist. */ diff --git a/packages/ui/src/components/OrganizationProfile/SSOBypassAllowlistPage.tsx b/packages/ui/src/components/OrganizationProfile/SSOBypassAllowlistPage.tsx index 3f2b9597840..48936bdfad5 100644 --- a/packages/ui/src/components/OrganizationProfile/SSOBypassAllowlistPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/SSOBypassAllowlistPage.tsx @@ -1,11 +1,15 @@ import { __internal_useOrganizationSSOBypassAllowlist, useOrganization, useUser } from '@clerk/shared/react'; import type { AddSSOBypassAllowlistUserParams, - OrganizationMembershipResource, + AddSSOBypassAllowlistUsersParams, + OrganizationCustomRoleKey, + OrganizationResource, + SSOBypassAllowlistBulkCreateResult, SSOBypassAllowlistUserResource, } from '@clerk/shared/types'; -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { ExclamationTriangle } from '@/icons'; import { Action } from '@/ui/elements/Action'; import { useActionContext } from '@/ui/elements/Action/ActionRoot'; import { Alert } from '@/ui/elements/Alert'; @@ -19,30 +23,124 @@ import { FormContainer } from '@/ui/elements/FormContainer'; import { Header } from '@/ui/elements/Header'; import { ProfileCard } from '@/ui/elements/ProfileCard'; import { SearchInput } from '@/ui/elements/SearchInput'; +import { SegmentedControl } from '@/ui/elements/SegmentedControl'; +import { SuccessPage } from '@/ui/elements/SuccessPage'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { UserPreview } from '@/ui/elements/UserPreview'; import { handleError } from '@/ui/utils/errorHandler'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import type { LocalizationKey } from '../../customizables'; import { Badge, Button, Col, descriptors, Flex, + Icon, localizationKeys, Td, Text, useLocalizations, } from '../../customizables'; +import { useWizard, Wizard } from '../../common'; +import { useFetchRoles } from '../../hooks/useFetchRoles'; import { mqu } from '../../styledSystem'; +import { RoleSelect } from './MemberListTable'; import { SecurityBackControl } from './SecurityBackControl'; type SSOBypassAllowlistPageProps = { onBack: () => void; }; -const MEMBER_SEARCH_DEBOUNCE_MS = 500; -const MEMBER_SEARCH_PAGE_SIZE = 10; +const MEMBER_LOOKUP_PAGE_SIZE = 10; +const ROLE_MEMBERS_PAGE_SIZE = 100; + +type BulkResult = { added: number; skipped: number }; +type AddMode = 'email' | 'role'; +type RoleOption = { value: string; label: string }; + +const findMemberByEmail = async (organization: OrganizationResource, email: string) => { + const { data } = await organization.getMemberships({ query: email, pageSize: MEMBER_LOOKUP_PAGE_SIZE }); + const wanted = email.toLowerCase(); + return data.find(membership => membership.publicUserData?.identifier?.toLowerCase() === wanted); +}; + +const useRoleMemberCounts = ( + organization: OrganizationResource | null | undefined, + roles: RoleOption[] | undefined, +) => { + const [counts, setCounts] = useState>({}); + const roleKeys = (roles ?? []).map(role => role.value).join(','); + + useEffect(() => { + if (!organization || !roleKeys) { + return; + } + let cancelled = false; + void Promise.all( + roleKeys.split(',').map(async role => { + const { total_count } = await organization.getMemberships({ role: [role], pageSize: 1 }); + return [role, total_count] as const; + }), + ) + .then(entries => { + if (!cancelled) { + setCounts(Object.fromEntries(entries)); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [organization, roleKeys]); + + return counts; +}; + +const collectUserIdsByRole = async (organization: OrganizationResource, role: OrganizationCustomRoleKey) => { + const userIds: string[] = []; + let fetched = 0; + for (let page = 1; ; page++) { + const { data, total_count } = await organization.getMemberships({ + role: [role], + pageSize: ROLE_MEMBERS_PAGE_SIZE, + initialPage: page, + }); + fetched += data.length; + data.forEach(membership => { + const userId = membership.publicUserData?.userId; + if (userId) { + userIds.push(userId); + } + }); + if (data.length === 0 || fetched >= total_count) { + return userIds; + } + } +}; + +const bulkResultText = (result: BulkResult): LocalizationKey[] => { + if (result.added === 0 && result.skipped === 0) { + return [localizationKeys('organizationProfile.securityPage.ssoBypassPage.bulkResult.none')]; + } + const added = + result.added === 1 + ? localizationKeys('organizationProfile.securityPage.ssoBypassPage.bulkResult.added__one') + : localizationKeys('organizationProfile.securityPage.ssoBypassPage.bulkResult.added', { + count: String(result.added), + }); + if (result.skipped === 0) { + return [added]; + } + const skipped = + result.skipped === 1 + ? localizationKeys('organizationProfile.securityPage.ssoBypassPage.bulkResult.skipped__one') + : localizationKeys('organizationProfile.securityPage.ssoBypassPage.bulkResult.skipped', { + count: String(result.skipped), + }); + return result.added === 0 ? [skipped] : [added, skipped]; +}; const matchesSearch = (entry: SSOBypassAllowlistUserResource, term: string): boolean => { const { firstName, lastName, identifier, username } = entry.publicUserData; @@ -54,7 +152,7 @@ export const SSOBypassAllowlistPage = withCardStateProvider(({ onBack }: SSOBypa const card = useCardState(); const { t } = useLocalizations(); const { user } = useUser(); - const { data, isLoading, error, addUser, removeUser } = __internal_useOrganizationSSOBypassAllowlist(); + const { data, isLoading, error, addUser, addUsers, removeUser } = __internal_useOrganizationSSOBypassAllowlist(); const [search, setSearch] = useState(''); const term = search.trim().toLowerCase(); @@ -128,6 +226,7 @@ export const SSOBypassAllowlistPage = withCardStateProvider(({ onBack }: SSOBypa @@ -220,17 +319,31 @@ const AllowlistRow = ({ entry, isCurrentUser, onRemove }: AllowlistRowProps): JS type AddMemberProps = { allowlistedUserIds: Set; addUser: (params: AddSSOBypassAllowlistUserParams) => Promise; + addUsers: (params: AddSSOBypassAllowlistUsersParams) => Promise; }; const AddMemberScreen = (props: AddMemberProps): JSX.Element => { const { close } = useActionContext(); + const wizard = useWizard(); + const [bulkResult, setBulkResult] = useState(null); return ( - + + { + setBulkResult(result); + wizard.nextStep(); + }} + /> + + ); }; @@ -238,64 +351,105 @@ const AddMemberForm = withCardStateProvider( ({ allowlistedUserIds, addUser, - onSuccess, + addUsers, + onResult, onReset, - }: AddMemberProps & { onSuccess: () => void; onReset: () => void }): JSX.Element => { + }: AddMemberProps & { + onReset: () => void; + onResult: (result: BulkResult) => void; + }): JSX.Element => { const card = useCardState(); const { t } = useLocalizations(); - const [search, setSearch] = useState(''); - const [query, setQuery] = useState(''); - const [selected, setSelected] = useState(null); - const debounceTimer = useRef | null>(null); - - const { memberships } = useOrganization({ - memberships: { - keepPreviousData: true, - pageSize: MEMBER_SEARCH_PAGE_SIZE, - query: query || undefined, - }, + const [mode, setMode] = useState('email'); + const [role, setRole] = useState(''); + const { organization } = useOrganization(); + const { options: roles } = useFetchRoles(); + const roleCounts = useRoleMemberCounts(organization, roles); + + const emailField = useFormControl('emailAddress', '', { + type: 'email', + label: localizationKeys('formFieldLabel__emailAddress'), + placeholder: localizationKeys('organizationProfile.securityPage.ssoBypassPage.addForm.emailPlaceholder'), + isRequired: true, }); - const options = (memberships?.data ?? []).filter(membership => { - const userId = membership.publicUserData?.userId; - return Boolean(userId) && !allowlistedUserIds.has(userId as string); - }); + const roleOptions = useMemo( + () => + (roles ?? []).map(option => { + const count = roleCounts[option.value]; + return count === undefined + ? option + : { + ...option, + label: t( + localizationKeys('organizationProfile.securityPage.ssoBypassPage.addForm.roleOption', { + role: option.label, + count: String(count), + }), + ), + }; + }), + [roles, roleCounts, t], + ); + + const email = emailField.value.trim(); + const canSubmit = !card.isLoading && (mode === 'email' ? email !== '' : Boolean(role)); + + const changeMode = (next: AddMode) => { + card.setError(undefined); + setMode(next); + }; - const handleSearchChange = (value: string) => { - setSearch(value); - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); + const addByEmail = async (): Promise => { + if (!organization) { + return; + } + const member = await findMemberByEmail(organization, email); + const userId = member?.publicUserData?.userId; + if (!userId) { + card.setError( + t(localizationKeys('organizationProfile.securityPage.ssoBypassPage.addForm.error__memberNotFound')), + ); + return; } - if (value.trim() === '') { - setQuery(''); + if (allowlistedUserIds.has(userId)) { + card.setError( + t(localizationKeys('organizationProfile.securityPage.ssoBypassPage.addForm.error__alreadyAdded')), + ); return; } - debounceTimer.current = setTimeout(() => setQuery(value.trim()), MEMBER_SEARCH_DEBOUNCE_MS); + await addUser({ userId }); + return { added: 1, skipped: 0 }; }; - const handleClear = () => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); + const addByRole = async (): Promise => { + if (!organization) { + return; + } + const userIds = (await collectUserIdsByRole(organization, role)).filter( + userId => !allowlistedUserIds.has(userId), + ); + if (userIds.length === 0) { + return { added: 0, skipped: 0 }; } - setSearch(''); - setQuery(''); + const added = await addUsers({ userIds }); + return { added: added?.data.length ?? 0, skipped: added?.errors.length ?? 0 }; }; - const selectedUserId = selected?.publicUserData?.userId; - const canSubmit = Boolean(selectedUserId) && !card.isLoading; - const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!selectedUserId || card.isLoading) { + if (!canSubmit) { return; } try { - await card.runAsync(() => addUser({ userId: selectedUserId })); - onSuccess(); + const result = await card.runAsync(mode === 'email' ? addByEmail : addByRole); + if (result) { + onResult(result); + } } catch (err) { - handleError(err as Error, [], card.setError); + handleError(err as Error, [emailField], card.setError); } }; @@ -305,111 +459,62 @@ const AddMemberForm = withCardStateProvider( headerSubtitle={localizationKeys('organizationProfile.securityPage.ssoBypassPage.addForm.subtitle')} > - - changeMode(next as AddMode)} + size='lg' + sx={{ alignSelf: 'flex-start' }} + > + - - {selected ? ( + + + + {mode === 'email' ? ( + + + + ) : ( + + ({ width: '100%', justifyContent: 'space-between', color: t.colors.$colorForeground })} + /> ({ - padding: `${t.space.$2} ${t.space.$3}`, - borderRadius: t.radii.$md, - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$borderAlpha150, - })} > - - - ))} - - {options.length === 0 && !memberships?.isLoading && ( - - )} - - - )} - + + )} { await userEvent.click(screen.getByRole('button', { name: /open menu/i })); await userEvent.click(await screen.findByRole('menuitem', { name: 'Manage' })); - expect(await screen.findByRole('heading', { name: 'SSO allow list' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'SSO bypass' })).toBeInTheDocument(); expect(screen.getByText('Cameron Walker')).toBeInTheDocument(); expect(screen.getByText('cameron@clerk.com')).toBeInTheDocument(); expect(screen.getByText('You')).toBeInTheDocument(); @@ -184,7 +184,7 @@ describe('SSO bypass allowlist', () => { await screen.findByText('SSO bypass'); await userEvent.click(screen.getByRole('button', { name: /open menu/i })); await userEvent.click(await screen.findByRole('menuitem', { name: 'Manage' })); - await screen.findByRole('heading', { name: 'SSO allow list' }); + await screen.findByRole('heading', { name: 'SSO bypass' }); }; it('filters the list by name or email', async () => { @@ -210,13 +210,13 @@ describe('SSO bypass allowlist', () => { expect(await screen.findByText('No members match your search')).toBeInTheDocument(); }); - it('adds a member picked from the organization members', async () => { + it('adds a member by email address', async () => { const { wrapper, fixtures } = await createFixtures( withSecurityPage({ permissions: ['org:sys_entconns:manage', 'org:sys_entconns_sso_bypass:manage'] }), ); fixtures.clerk.organization?.getMemberships.mockResolvedValue({ - data: [membership('user_1', 'Cameron', 'cameron@clerk.com'), membership('user_9', 'Yukio', 'yukio@clerk.com')], - total_count: 2, + data: [membership('user_9', 'Yukio', 'yukio@clerk.com')], + total_count: 1, } as any); fixtures.clerk.organization?.ssoBypassAllowlist.addUser.mockResolvedValue( allowlistEntry('user_9', 'Yukio', 'yukio@clerk.com'), @@ -228,27 +228,28 @@ describe('SSO bypass allowlist', () => { await userEvent.click(screen.getByRole('button', { name: 'Add' })); - expect(await screen.findByRole('heading', { name: 'Add member' })).toBeInTheDocument(); - const submitButton = () => screen.getAllByRole('button', { name: 'Add' })[1]; - expect(submitButton()).toBeDisabled(); - - const options = await screen.findAllByRole('option'); - expect(options).toHaveLength(1); - expect(options[0]).toHaveTextContent('Yukio Yamamoto'); + expect(await screen.findByRole('heading', { name: 'Add members' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Email' })).toBeChecked(); + expect(screen.getByRole('button', { name: 'Add members' })).toBeDisabled(); - await userEvent.click(options[0]); - expect(screen.queryByRole('option')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Change' })).toBeInTheDocument(); - - await userEvent.click(submitButton()); + await userEvent.type(screen.getByLabelText('Email address'), 'Yukio@clerk.com'); + await userEvent.click(screen.getByRole('button', { name: 'Add members' })); + await waitFor(() => + expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalledWith( + expect.objectContaining({ query: 'Yukio@clerk.com' }), + ), + ); await waitFor(() => expect(fixtures.clerk.organization?.ssoBypassAllowlist.addUser).toHaveBeenCalledWith({ userId: 'user_9' }), ); - await waitFor(() => expect(screen.queryByRole('heading', { name: 'Add member' })).not.toBeInTheDocument()); + expect(await screen.findByText('Added 1 member to the allow list.')).toBeInTheDocument(); + + await userEvent.click(await screen.findByRole('button', { name: 'Finish' })); + await waitFor(() => expect(screen.queryByRole('heading', { name: 'Add members' })).not.toBeInTheDocument()); }); - it('searches members through the organization membership query', async () => { + it('explains when no member has the email address', async () => { const { wrapper, fixtures } = await createFixtures( withSecurityPage({ permissions: ['org:sys_entconns:manage', 'org:sys_entconns_sso_bypass:manage'] }), ); @@ -257,23 +258,77 @@ describe('SSO bypass allowlist', () => { total_count: 1, } as any); - vi.useFakeTimers({ shouldAdvanceTime: true }); - try { - const { userEvent } = await openAllowlistPage(wrapper, fixtures, []); + const { userEvent } = await openAllowlistPage(wrapper, fixtures, []); - await userEvent.click(screen.getByRole('button', { name: 'Add' })); - await userEvent.type(await screen.findByRole('searchbox', { name: 'Search members' }), 'yukio'); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.type(await screen.findByLabelText('Email address'), 'nobody@clerk.com'); + await userEvent.click(screen.getByRole('button', { name: 'Add members' })); + + expect(await screen.findByText('No member of this organization has that email address.')).toBeInTheDocument(); + expect(fixtures.clerk.organization?.ssoBypassAllowlist.addUser).not.toHaveBeenCalled(); + expect(screen.getByRole('heading', { name: 'Add members' })).toBeInTheDocument(); + }); + + it('adds every member with the selected role and reports the skipped ones', async () => { + const { wrapper, fixtures } = await createFixtures( + withSecurityPage({ + permissions: ['org:sys_entconns:manage', 'org:sys_entconns_sso_bypass:manage', 'org:sys_memberships:read'], + }), + ); + fixtures.clerk.organization?.getRoles.mockResolvedValue({ + data: [ + { id: 'role_admin', key: 'org:admin', name: 'Admin' }, + { id: 'role_member', key: 'org:member', name: 'Member' }, + ], + total_count: 2, + } as any); + fixtures.clerk.organization?.getMemberships.mockResolvedValue({ + data: [ + membership('user_1', 'Cameron', 'cameron@clerk.com'), + membership('user_9', 'Yukio', 'yukio@clerk.com'), + membership('user_10', 'Dana', 'dana@personal.com'), + ], + total_count: 3, + } as any); + fixtures.clerk.organization?.ssoBypassAllowlist.addUsers.mockResolvedValue({ + data: [allowlistEntry('user_9', 'Yukio', 'yukio@clerk.com')], + errors: [{ userId: 'user_10', code: 'sso_bypass_domain_not_served' }], + }); + + const { userEvent } = await openAllowlistPage(wrapper, fixtures, [ + allowlistEntry('user_1', 'Cameron', 'cameron@clerk.com'), + ]); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(await screen.findByRole('heading', { name: 'Add members' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', { name: 'Role' })); + expect(screen.getByText('This list does not sync. Members are added and removed manually.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add members' })).toBeDisabled(); + + await userEvent.click(screen.getByRole('button', { name: /select role/i })); + await userEvent.click(await screen.findByText('Admin (3)')); + await userEvent.click(screen.getByRole('button', { name: 'Add members' })); + + await waitFor(() => + expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalledWith( + expect.objectContaining({ role: ['org:admin'], initialPage: 1 }), + ), + ); + await waitFor(() => + expect(fixtures.clerk.organization?.ssoBypassAllowlist.addUsers).toHaveBeenCalledWith({ + userIds: ['user_9', 'user_10'], + }), + ); - await vi.advanceTimersByTimeAsync(600); + expect(await screen.findByText('Added 1 member to the allow list.')).toBeInTheDocument(); + expect( + screen.getByText('1 member could not be added because their email address is not served by a connection.'), + ).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { name: 'Add members' }).length).toBeGreaterThan(0); - await waitFor(() => - expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalledWith( - expect.objectContaining({ query: 'yukio' }), - ), - ); - } finally { - vi.useRealTimers(); - } + await userEvent.click(await screen.findByRole('button', { name: 'Finish' })); + await waitFor(() => expect(screen.queryByRole('heading', { name: 'Add members' })).not.toBeInTheDocument()); }); it('removes a member from the row menu', async () => { diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 810301c4f0f..f406692387d 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -249,10 +249,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'organizationProfileSecuritySsoBypassSearchInput', 'organizationProfileSecuritySsoBypassSearchInputIcon', 'organizationProfileSecuritySsoBypassAddButton', - 'organizationProfileSecuritySsoBypassMemberSearchInput', - 'organizationProfileSecuritySsoBypassMemberSearchInputIcon', - 'organizationProfileSecuritySsoBypassMemberOptions', - 'organizationProfileSecuritySsoBypassMemberOption', + 'organizationProfileSecuritySsoBypassEmailInput', + 'organizationProfileSecuritySsoBypassRoleWarning', + 'organizationProfileSecuritySsoBypassBulkResult', 'organizationListPreviewItems', 'organizationListPreviewItem', diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index e49f6115fd8..adda82af778 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -389,10 +389,9 @@ export type ElementsConfig = { organizationProfileSecuritySsoBypassSearchInput: WithOptions; organizationProfileSecuritySsoBypassSearchInputIcon: WithOptions; organizationProfileSecuritySsoBypassAddButton: WithOptions; - organizationProfileSecuritySsoBypassMemberSearchInput: WithOptions; - organizationProfileSecuritySsoBypassMemberSearchInputIcon: WithOptions; - organizationProfileSecuritySsoBypassMemberOptions: WithOptions; - organizationProfileSecuritySsoBypassMemberOption: WithOptions; + organizationProfileSecuritySsoBypassEmailInput: WithOptions; + organizationProfileSecuritySsoBypassRoleWarning: WithOptions; + organizationProfileSecuritySsoBypassBulkResult: WithOptions; organizationListPreviewItems: WithOptions; organizationListPreviewItem: WithOptions;