Skip to content
Draft
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
7 changes: 5 additions & 2 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PageMocking, type MockScenario } from '@clerk/msw';
import { type MockScenario, PageMocking } from '@clerk/msw';

import * as l from '../../localizations';
import { dark, neobrutalism, shadcn, shadesOfPurple } from '../../ui/src/themes';
import type { Clerk as ClerkType } from '../';
Expand Down Expand Up @@ -350,7 +351,9 @@ function themeSelector() {
type Preset = { elements: Record<string, any>; options?: Record<string, any>; variables?: Record<string, any> };

function presetToAppearance(preset: Preset | undefined) {
if (!preset) return {};
if (!preset) {
return {};
}
return {
elements: preset.elements,
...(preset.options ? { options: preset.options } : {}),
Expand Down
30 changes: 26 additions & 4 deletions packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react';
import { useClerk, useOAuthConsent, useOrganization, useOrganizationList, useUser } from '@clerk/shared/react';
import { useState } from 'react';

import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts';
Expand Down Expand Up @@ -37,15 +37,35 @@ function _OAuthConsent() {
// TODO(rob): Implement lazy loading in another PR
userMemberships: ctx.enableOrgSelection ? { infinite: true, pageSize: 50 } : undefined,
});
const { organization: activeOrg } = useOrganization();

const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({
value: m.organization.id,
label: m.organization.name,
logoUrl: m.organization.imageUrl,
}));

// TEMP: Synthetic orgs for manual infinite-scroll testing.
// Remove in follow-up once testing with a real account that has 10+ orgs.
const [syntheticPage, setSyntheticPage] = useState(1);
const syntheticOrgs: OrgOption[] = ctx.enableOrgSelection
? Array.from({ length: syntheticPage * 5 }, (_, i) => ({
value: `synthetic_org_${i + 1}`,
label: `Synthetic Org ${i + 1}`,
logoUrl: orgOptions[0]?.logoUrl ?? '',
}))
: [];
const mergedOrgOptions = ctx.enableOrgSelection ? [...orgOptions, ...syntheticOrgs] : orgOptions;
const syntheticHasMore = ctx.enableOrgSelection && syntheticPage < 4; // 4 pages x 5 = 20 total
const syntheticFetchNext = () => setSyntheticPage(p => p + 1);
// TEMP END

const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null;
const effectiveOrg =
selectedOrg ??
(activeOrg ? mergedOrgOptions.find(o => o.value === activeOrg.id)?.value : undefined) ??
mergedOrgOptions[0]?.value ??
null;

// onAllow and onDeny are always provided as a pair by the accounts portal.
const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny);
Expand Down Expand Up @@ -228,11 +248,13 @@ function _OAuthConsent() {
})}
/>
</Header.Root>
{ctx.enableOrgSelection && orgOptions.length > 0 && effectiveOrg && (
{ctx.enableOrgSelection && mergedOrgOptions.length > 0 && effectiveOrg && (
<OrgSelect
options={orgOptions}
options={mergedOrgOptions}
value={effectiveOrg}
onChange={setSelectedOrg}
hasMore={syntheticHasMore}
onLoadMore={syntheticFetchNext}
/>
)}
<ListGroup>
Expand Down
16 changes: 14 additions & 2 deletions packages/ui/src/components/OAuthConsent/OrgSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useRef } from 'react';

import { InfiniteListSpinner } from '@/ui/common/InfiniteListSpinner';
import { Box, Icon, Image, Text } from '@/ui/customizables';
import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select';
import { useInView } from '@/ui/hooks/useInView';
import { Check } from '@/ui/icons';
import { common } from '@/ui/styledSystem';

Expand All @@ -15,11 +17,21 @@ type OrgSelectProps = {
options: OrgOption[];
value: string | null;
onChange: (value: string) => void;
hasMore?: boolean;
onLoadMore?: () => void;
};

export function OrgSelect({ options, value, onChange }: OrgSelectProps) {
export function OrgSelect({ options, value, onChange, hasMore, onLoadMore }: OrgSelectProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const selected = options.find(option => option.value === value);
const { ref: loadMoreRef } = useInView({
threshold: 0,
onChange: inView => {
if (inView && hasMore) {
onLoadMore?.();
}
},
});

return (
<Select
Expand Down Expand Up @@ -101,7 +113,7 @@ export function OrgSelect({ options, value, onChange }: OrgSelectProps) {
{selected?.label || 'Select an option'}
</Text>
</SelectButton>
<SelectOptionList />
<SelectOptionList footer={hasMore ? <InfiniteListSpinner ref={loadMoreRef} /> : null} />
</Select>
);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useOrganization, useOrganizationList } from '@clerk/shared/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useOrganizationList } from '@clerk/shared/react';

import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, waitFor } from '@/test/utils';

import { OAuthConsent } from '../OAuthConsent';

// Captures the onChange injected into SelectOptionList's useInView so tests
// can simulate "user scrolled to the bottom of the org dropdown".
let capturedLoadMoreOnChange: ((inView: boolean) => void) | undefined;

// Default: useOrganizationList returns no memberships and is not loaded.
// Individual tests override this mock to inject org data.
vi.mock('@clerk/shared/react', async importOriginal => {
Expand All @@ -15,11 +18,19 @@ vi.mock('@clerk/shared/react', async importOriginal => {
...actual,
useOrganizationList: vi.fn().mockReturnValue({
isLoaded: false,
userMemberships: { data: [] },
userMemberships: { data: [], hasNextPage: false, fetchNext: vi.fn(), isLoading: false },
}),
useOrganization: vi.fn().mockReturnValue({ organization: null }),
};
});

vi.mock('@/ui/hooks/useInView', () => ({
useInView: vi.fn().mockImplementation(({ onChange }: { onChange?: (inView: boolean) => void }) => {
capturedLoadMoreOnChange = onChange;
return { ref: vi.fn(), inView: false };
}),
}));

const { createFixtures } = bindCreateFixtures('OAuthConsent');

const fakeConsentInfo = {
Expand Down Expand Up @@ -56,6 +67,7 @@ describe('OAuthConsent', () => {
const originalLocation = window.location;

beforeEach(() => {
capturedLoadMoreOnChange = undefined;
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
Expand Down Expand Up @@ -431,4 +443,85 @@ describe('OAuthConsent', () => {
});
});
});

describe('org selection — infinite scroll and active-org pre-selection', () => {
const twoOrgs = [
{ organization: { id: 'org_1', name: 'Acme Corp', imageUrl: 'https://img.clerk.com/static/clerk.png' } },
{ organization: { id: 'org_2', name: 'Beta Inc', imageUrl: 'https://img.clerk.com/static/beta.png' } },
];

it('wires the load-more sentinel to the onLoadMore callback', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({ email_addresses: ['jane@example.com'] });
});

props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });

vi.mocked(useOrganizationList).mockReturnValue({
isLoaded: true,
userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext: vi.fn(), isLoading: false },
} as any);

render(<OAuthConsent />, { wrapper });

// The load-more sentinel is always wired up when enableOrgSelection is true
// (syntheticHasMore starts at true since syntheticPage=1 < 4)
await waitFor(() => {
expect(capturedLoadMoreOnChange).toBeDefined();
});

// Calling it should not throw — it calls syntheticFetchNext which updates state
expect(() => capturedLoadMoreOnChange!(true)).not.toThrow();
});

it('pre-selects the active organization when the session has one', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({ email_addresses: ['jane@example.com'] });
});

props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });

vi.mocked(useOrganizationList).mockReturnValue({
isLoaded: true,
userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext: vi.fn(), isLoading: false },
} as any);

// Active org is org_2 — second in list, not first, to prove ordering matters
vi.mocked(useOrganization).mockReturnValue({ organization: { id: 'org_2' } } as any);

const { baseElement } = render(<OAuthConsent />, { wrapper });

await waitFor(() => {
const form = baseElement.querySelector('form[action*="/v1/me/oauth/consent/"]')!;
const hiddenInput = form.querySelector('input[name="organization_id"]') as HTMLInputElement | null;
expect(hiddenInput?.value).toBe('org_2');
});
});

it('falls back to the first org when the session has no active organization', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({ email_addresses: ['jane@example.com'] });
});

props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });

vi.mocked(useOrganizationList).mockReturnValue({
isLoaded: true,
userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext: vi.fn(), isLoading: false },
} as any);

vi.mocked(useOrganization).mockReturnValue({ organization: null } as any);

const { baseElement } = render(<OAuthConsent />, { wrapper });

await waitFor(() => {
const form = baseElement.querySelector('form[action*="/v1/me/oauth/consent/"]')!;
const hiddenInput = form.querySelector('input[name="organization_id"]') as HTMLInputElement | null;
expect(hiddenInput?.value).toBe('org_1');
});
});
});
});
4 changes: 3 additions & 1 deletion packages/ui/src/elements/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,11 @@ export const SelectNoResults = (props: PropsOfComponent<typeof Text>) => {

type SelectOptionListProps = PropsOfComponent<typeof Flex> & {
containerSx?: ThemableCssProp;
footer?: React.ReactNode;
};

export const SelectOptionList = (props: SelectOptionListProps) => {
const { containerSx, sx, ...rest } = props;
const { containerSx, sx, footer, ...rest } = props;
const {
popoverCtx,
searchInputCtx,
Expand Down Expand Up @@ -376,6 +377,7 @@ export const SelectOptionList = (props: SelectOptionListProps) => {
);
})}
{noResultsMessage && options.length === 0 && <SelectNoResults>{noResultsMessage}</SelectNoResults>}
{footer}
</Flex>
</Flex>
</Popover>
Expand Down
Loading