Skip to content
12 changes: 12 additions & 0 deletions .changeset/sso-bypass-allowlist-by-role.md
Original file line number Diff line number Diff line change
@@ -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 `<OrganizationProfile />` 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.
24 changes: 24 additions & 0 deletions packages/clerk-js/src/core/resources/SSOBypassAllowlist.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type {
AddSSOBypassAllowlistUserParams,
AddSSOBypassAllowlistUsersParams,
DeletedObjectJSON,
DeletedObjectResource,
SSOBypassAllowlistBulkCreateJSON,
SSOBypassAllowlistBulkCreateResult,
SSOBypassAllowlistResource,
SSOBypassAllowlistUserJSON,
SSOBypassAllowlistUserResource,
Expand All @@ -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 };

Expand Down Expand Up @@ -45,6 +50,25 @@ export class SSOBypassAllowlist implements SSOBypassAllowlistResource {
return new SSOBypassAllowlistUser(json);
};

addUsers = async (params: AddSSOBypassAllowlistUsersParams): Promise<SSOBypassAllowlistBulkCreateResult> => {
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<DeletedObjectResource> => {
const json = (
await BaseResource._fetch<DeletedObjectJSON>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/bn-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/ca-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/cs-CZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/da-DK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/de-DE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/localizations/src/el-GR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading