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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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/old-dancers-judge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix the self-serve SSO configuration wizard losing your place when organization data refetches mid-flow. After submitting a Configure step (for example saving an identity provider's metadata), a background refetch on the OrganizationProfile Security page could unmount the open ConfigureSSO wizard and re-render it on an earlier step. The wizard now stays on its current step while data loads in the background.
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,13 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
setView('wizard');
};

if (isLoading) {
// Gate the page-level loading overview to the overview view only. A wizard is
// only ever opened after the overview has settled (it gates on `isLoading`),
// so once `view === 'wizard'` the connection data is present and stays warm; a
// later `isLoading` flip (e.g. the test-runs query cold-loading after a
// configure write) must not tear the open wizard down and reseat it — each
// wizard step owns its own loading UI.
if (isLoading && view === 'overview') {
return (
<SecurityPageOverview fillHeight>
<Flex
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

import { organizationEnterpriseConnection as buildOrganizationEnterpriseConnection } from '../../ConfigureSSO/domain/organizationEnterpriseConnection';

// External, test-controllable loading flag. The mocked umbrella hook reads it
// through `useSyncExternalStore`, so flipping it inside `act` triggers a real
// re-render of the page — exactly how a mid-wizard refetch toggles `isLoading`
// in production (the test-runs query cold-loading after a configure write).
const loadingStore = vi.hoisted(() => {
let loading = false;
const listeners = new Set<() => void>();
return {
get: () => loading,
set: (next: boolean) => {
loading = next;
listeners.forEach(l => l());
},
subscribe: (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
});

// An active, fully-configured connection with all domains verified and a
// successful test run. Every wizard step is reachable, so the furthest-reachable
// seed is the last step (`activate`).
const activeConnection = {
id: 'ent_1',
name: 'clerk.com',
provider: 'saml_okta',
active: true,
organizationId: 'Org1',
domains: ['clerk.com'],
samlConnection: {
idpSsoUrl: 'https://idp.example.com/sso',
idpEntityId: 'https://idp.example.com/entity',
idpCertificate: 'CERT',
},
} as any;

const verifiedDomain = {
id: 'dmn_verified',
name: 'clerk.com',
organizationId: 'Org1',
enrollmentMode: 'enterprise_sso',
ownershipVerification: { status: 'verified', strategy: 'txt' },
} as any;

const noop = () => Promise.resolve(undefined);

// Mock the umbrella hook so the test owns `isLoading` and the connection state
// directly, while the real OrganizationSecurityPage / ConfigureSSOWizard / Wizard
// render. This isolates the bug to the page's loading-vs-view gating.
vi.mock('../../ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({
useOrganizationEnterpriseConnection: () => {
const isLoading = React.useSyncExternalStore(loadingStore.subscribe, loadingStore.get, loadingStore.get);
return {
isLoading,
user: { primaryEmailAddress: { emailAddress: 'test@clerk.com' } },
session: {},
organization: { name: 'Org1' },
enterpriseConnection: activeConnection,
organizationEnterpriseConnection: buildOrganizationEnterpriseConnection({
connection: activeConnection,
hasSuccessfulTestRun: true,
}),
enterpriseConnectionMutations: {
createConnection: noop,
changeProvider: noop,
updateConnection: noop,
setConnectionActive: noop,
deleteConnection: noop,
createTestRun: noop,
},
testRuns: {
rows: [{ id: 'run_1', status: 'success' }],
totalCount: 1,
isLoading: false,
isFetching: false,
isPolling: false,
page: 1,
setPage: () => {},
refresh: noop,
},
organizationDomains: [verifiedDomain],
organizationDomainMutations: {
createDomain: noop,
prepareOwnershipVerification: noop,
attemptOwnershipVerification: noop,
revalidate: noop,
},
};
},
}));

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

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

const withSecurityPageFixtures = (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
f.withEnterpriseSso({ selfServeSSO: true });
f.withEmailAddress();
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }],
});
};

describe('OrganizationSecurityPage — wizard survives a mid-flow loading toggle', () => {
it('keeps the open wizard on its current step when isLoading flips true→false', async () => {
loadingStore.set(false);
const { wrapper } = await createFixtures(withSecurityPageFixtures);

const { userEvent } = render(<OrganizationSecurityPage contentRef={{ current: null }} />, { wrapper });

// Enter the wizard from the overview via Edit, which forces the first step.
await userEvent.click(await screen.findByRole('button', { name: /open menu/i }));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument();

// Navigate forward to the Activate step via the breadcrumb (reachable because
// the connection is active). This puts the user on a step OTHER than the
// forced seed, so a reseat is observable.
await userEvent.click(screen.getByRole('button', { name: /^Activate$/ }));
expect(await screen.findByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();

// A transient refetch: isLoading flips true then back to false while the user
// is mid-wizard. The wizard must NOT unmount and reseat.
act(() => loadingStore.set(true));
act(() => loadingStore.set(false));

// The wizard stays on the Activate step; it did not snap back to the forced
// first step (Domains). On the unfixed page-level gate the wizard unmounts
// during the `true` frame and remounts at the forced first step, so the
// Activate heading is gone and "Add SSO domains" is shown instead.
expect(screen.getByRole('heading', { name: /SSO connection is active/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /add SSO domains/i })).not.toBeInTheDocument();
});
});
Loading