') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(ui): Automatically send invites after checkout by dstaley · Pull Request #8869 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/invite-checkout-complete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

When inviting organization members requires purchasing additional seats, invitations are now sent automatically after checkout completes successfully.
37 changes: 13 additions & 24 deletions integration/tests/per-seat-pricing.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,9 +31,6 @@ testAgainstRunningApps({})('per-seat pricing @billing', ({ app }) => {
const emailInput = u.po.page.getByTestId('tag-input');
await emailInput.fill(INVITEE_EMAIL);
await emailInput.press('Enter');

await u.po.page.getByRole('button', { name: /select role/i }).click();
await u.po.page.getByRole('option', { name: /^member$/i }).click();
};

await u.po.signIn.goTo();
Expand DownExpand Up@@ -74,27 +71,19 @@ testAgainstRunningApps({})('per-seat pricing @billing', ({ app }) => {
await expect(u.po.checkout.root.getByText('Payment was successful!')).toBeVisible({
timeout: 15_000,
});
await u.po.checkout.confirmAndContinue();
await u.po.checkout.root.waitFor({ state: 'hidden', timeout: 15_000 });

await u.po.organizationProfile.goTo();
await u.po.page.getByText(/^Members$/).click();
await expect(u.po.page.getByRole('heading', { name: 'Members' })).toBeVisible();
await u.po.page.getByRole('button', { name: 'Invite' }).click();
await fillInviteForm();

const sendInvitationsButton = u.po.page.getByRole('button', { name: 'Send invitations' });
await expect(sendInvitationsButton).toBeEnabled({ timeout: 15_000 });
await sendInvitationsButton.click({ timeout: 15_000 });

await expect(u.po.page.getByText('Invitations successfully sent')).toBeVisible({
timeout: 15_000,
});
await u.po.page.getByRole('button', { name: 'Finish' }).click();

await u.po.page.getByRole('tab', { name: /Invitations/i }).click();
await expect(u.po.page.getByText(INVITEE_EMAIL)).toBeVisible({
timeout: 15_000,
});
await expect
.poll(
async () => {
const { data } = await u.services.clerk.organizations.getOrganizationInvitationList({
organizationId: fakeOrganization.organization.id,
status: ['pending'],
});

return data.some(invitation => invitation.emailAddress === INVITEE_EMAIL);
},
{ timeout: 15_000 },
)
.toBe(true);
});
});
257 changes: 158 additions & 99 deletions packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,11 @@ type InviteMembersFormProps = {
resetButtonLabel?: LocalizationKey;
};

type InviteMembersParams = {
emailAddresses: string[];
role: string;
};

export const InviteMembersForm = (props: InviteMembersFormProps) => {
const { onSuccess, onReset, resetButtonLabel } = props;
const clerk = useClerk();
Expand DownExpand Up@@ -101,127 +106,181 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {

const submittedData = new FormData(e.currentTarget);
const portalRoot = getClosestProfileScrollBoxFromElement(e.currentTarget);
try {
await organization.inviteMembers({
emailAddresses: emailAddressField.value.split(','),
role: submittedData.get('role') as string,
});
const inviteMembersParams: InviteMembersParams = {
emailAddresses: emailAddressField.value.split(','),
role: submittedData.get('role') as string,
};

await invitations?.revalidate?.();
onSuccess?.();
try {
await inviteMembers(inviteMembersParams);
} catch (err) {
if (!isClerkAPIResponseError(err)) {
if (err instanceof Error) {
handleError(err, [], card.setError);
return;
}
await handleInviteMembersError(err, inviteMembersParams, { portalRoot });
}
};

/**
* Attempt to invite members to an organization. Throws errors if unsuccessful.
*/
const inviteMembers = async (params: InviteMembersParams) => {
await organization.inviteMembers(params);

await invitations?.revalidate?.();
onSuccess?.();
};

throw err;
/**
* Attempt to invite members after a checkout has been performed. If inviting members throws an error, handle the
* error without triggering the checkout flow again.
*/
const inviteMembersAfterCheckout = (params: InviteMembersParams) => {
void (async () => {
card.setLoading();
card.setError(undefined);

try {
await inviteMembers(params);
} catch (err) {
await handleInviteMembersError(err, params, { openCheckoutOnInsufficientSeats: false });
} finally {
card.setIdle();
}
})();
};

/**
* Handle errors that are thrown after attempting to invite members to an organization.
*/
const handleInviteMembersError = async (
err: unknown,
inviteMembersParams: InviteMembersParams,
{
openCheckoutOnInsufficientSeats = true,
portalRoot,
}: { openCheckoutOnInsufficientSeats?: boolean; portalRoot?: HTMLElement | null } = {},
) => {
if (!isClerkAPIResponseError(err)) {
if (err instanceof Error) {
handleError(err, [], card.setError);
return;
}

throw err;
}
Comment on lines +161 to +168

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid rethrowing unknown values from this shared error handler.

This helper is also used by the fire-and-forget checkout callback path; rethrowing non-Error values can become an unhandled promise rejection instead of a surfaced UI error.

Suggested fix
 if (!isClerkAPIResponseError(err)) {
- if (err instanceof Error) {- handleError(err, [], card.setError);- return;- }-- throw err;+ const normalizedError = err instanceof Error ? err : new Error('Unknown invite error');+ handleError(normalizedError, [], card.setError);+ return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx` around
lines 151 - 158, The error handling logic in the InviteMembersForm.tsx file has
a risk of creating unhandled promise rejections when used in fire-and-forget
callback paths. In the error handler that checks for ClerkAPIResponseError, when
the error is not a ClerkAPIResponseError and not an Error instance, the code
currently rethrows the unknown value. Instead of rethrowing err directly in the
else branch after the `if (err instanceof Error)` check, wrap the unknown value
as an Error object or call handleError with it to ensure all error paths result
in surfaced UI errors rather than unhandled promise rejections.


removeInvalidEmails(err.errors[0]);

switch (err.errors?.[0]?.code) {
case 'duplicate_record': {
const unlocalizedEmailsList = err.errors[0].meta?.emailAddresses || [];
card.setError(
t(
localizationKeys('organizationProfile.invitePage.detailsTitle__inviteFailed', {
// Create a localized list of email addresses
email_addresses: createListFormat(unlocalizedEmailsList, locale),
}),
),
const apiError = err.errors[0];

removeInvalidEmails(apiError, inviteMembersParams.emailAddresses);

switch (apiError?.code) {
case 'duplicate_record': {
const unlocalizedEmailsList = apiError.meta?.emailAddresses || [];
card.setError(
t(
localizationKeys('organizationProfile.invitePage.detailsTitle__inviteFailed', {
// Create a localized list of email addresses
email_addresses: createListFormat(unlocalizedEmailsList, locale),
}),
),
);
break;
}
case 'already_a_member_in_organization': {
/**
* Extracts email from the error message since it's not provided in the error response
*/
const longMessage = apiError.longMessage ?? '';
const email = longMessage.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/)?.[0];

handleError(err, [], err =>
email
? /**
* Fallbacks to original error message in case the email cannot be extracted
*/
card.setError(
t(
localizationKeys('unstable__errors.already_a_member_in_organization', {
email,
}),
),
)
: card.setError(err),
);

break;
}
case 'insufficient_seats': {
// If we get an insufficient_seats error and this function was invoked with openCheckoutOnInsufficientSeats
// set to false, we can immediately render an error instead of beginning the checkout flow.
if (!openCheckoutOnInsufficientSeats) {
handleError(err, [], () =>
card.setError(t(localizationKeys('unstable__errors.insufficient_seats_change_plan'))),
);
break;
}
case 'already_a_member_in_organization': {
/**
* Extracts email from the error message since it's not provided in the error response
*/
const longMessage = err.errors[0].longMessage ?? '';
const email = longMessage.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/)?.[0];

handleError(err, [], err =>
email
? /**
* Fallbacks to original error message in case the email cannot be extracted
*/
card.setError(
t(
localizationKeys('unstable__errors.already_a_member_in_organization', {
email,
}),
),
)
: card.setError(err),
);

break;
}
case 'insufficient_seats': {
try {
const { data: plans } = await clerk.billing.getPlans({
for: 'organization',
orgId: organization.id,
minSeats: err.errors[0].meta?.seatsQuantity,
// TODO(billing): update to multiple calls
pageSize: 500,
});

if (plans.length === 0) {
handleError(err, [], () =>
card.setError(t(localizationKeys('unstable__errors.insufficient_seats_contact_support'))),
);
break;
}
try {
const { data: plans } = await clerk.billing.getPlans({
for: 'organization',
orgId: organization.id,
minSeats: apiError.meta?.seatsQuantity,
// TODO(billing): update to multiple calls
pageSize: 500,
});

if (plans.length === 0) {
handleError(err, [], () =>
card.setError(t(localizationKeys('unstable__errors.insufficient_seats_contact_support'))),
);
break;
}

// we want the "current" subscription item, which can have either "active" or "past_due" status
const activeSubscriptionItem = subscriptionItems.find(
si => si.status === 'active' || si.status === 'past_due',
// we want the "current" subscription item, which can have either "active" or "past_due" status
const activeSubscriptionItem = subscriptionItems.find(
si => si.status === 'active' || si.status === 'past_due',
);
if (activeSubscriptionItem) {
const currentPlan = activeSubscriptionItem.plan;
const currentPlanAndPriceSupportsDesiredSeatQuantity = plans.some(
p =>
p.id === currentPlan.id &&
p.availablePrices?.some(price => price.id === activeSubscriptionItem.priceId),
);
if (activeSubscriptionItem) {
const currentPlan = activeSubscriptionItem.plan;
const currentPlanAndPriceSupportsDesiredSeatQuantity = plans.some(
p =>
p.id === currentPlan.id &&
p.availablePrices?.some(price => price.id === activeSubscriptionItem.priceId),
);
if (currentPlanAndPriceSupportsDesiredSeatQuantity) {
handleSelectPlan({
mode: 'modal',
plan: currentPlan,
planPeriod: activeSubscriptionItem.planPeriod,
seatsQuantity: err.errors[0].meta?.seatsQuantity,
priceId: activeSubscriptionItem.priceId,
portalRoot,
});
break;
}
if (currentPlanAndPriceSupportsDesiredSeatQuantity) {
handleSelectPlan({
mode: 'modal',
plan: currentPlan,
planPeriod: activeSubscriptionItem.planPeriod,
seatsQuantity: apiError.meta?.seatsQuantity,
priceId: activeSubscriptionItem.priceId,
portalRoot,
// once the checkout process completes, attempt to invite the members a second time
onSubscriptionComplete: () => inviteMembersAfterCheckout(inviteMembersParams),
});
break;
}
}

handleError(err, [], () =>
card.setError(t(localizationKeys('unstable__errors.insufficient_seats_change_plan'))),
);
break;
} catch (err: unknown) {
if (err instanceof Error) {
handleError(err, [], () =>
card.setError(t(localizationKeys('unstable__errors.insufficient_seats_change_plan'))),
card.setError(t(localizationKeys('unstable__errors.insufficient_seats_contact_support'))),
);
break;
} catch (err: unknown) {
if (err instanceof Error) {
handleError(err, [], () =>
card.setError(t(localizationKeys('unstable__errors.insufficient_seats_contact_support'))),
);
}
break;
}
break;
}
default: {
handleError(err, [], card.setError);
}
}
default: {
handleError(err, [], card.setError);
}
}
};

const removeInvalidEmails = (err: ClerkAPIError) => {
const removeInvalidEmails = (err: ClerkAPIError, emails: string[] = emailAddressField.value.split(',')) => {
const invalidEmails = new Set([...(err.meta?.emailAddresses ?? []), ...(err.meta?.identifiers ?? [])]);
const emails = emailAddressField.value.split(',');
emailAddressField.setValue(emails.filter(e => !invalidEmails.has(e)).join(','));
};

Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/contexts/components/Plans.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,6 +103,7 @@ type HandleSelectPlanProps = {
portalRoot?: HTMLElement | null;
appearance?: Appearance;
newSubscriptionRedirectUrl?: string;
onSubscriptionComplete?: () => void;
};

export const usePlansContext = () => {
Expand DownExpand Up@@ -347,6 +348,7 @@ export const usePlansContext = () => {
portalRoot: providedPortalRoot,
appearance,
newSubscriptionRedirectUrl,
onSubscriptionComplete,
}: HandleSelectPlanProps) => {
const portalRoot = providedPortalRoot ?? getClosestProfileScrollBox(mode, event);

Expand All@@ -359,6 +361,7 @@ export const usePlansContext = () => {
priceId,
onSubscriptionComplete: () => {
revalidateAll();
onSubscriptionComplete?.();
},
onClose: () => {
if (session?.id) {
Expand Down
Loading