From c19e6f6ee186277dd3c03ebf5d5a2ca1d48d89b2 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Wed, 8 Oct 2025 12:32:48 +0100 Subject: [PATCH 01/22] Update import paths in ClerkSessionSynchronizer to use relative paths fixing duplicate eventLoop Error --- custom_components/reflex_clerk_api/clerk_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 7de6e40..4bbba71 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -301,8 +301,8 @@ def add_imports( addl_imports: rx.ImportDict = { "@clerk/clerk-react": ["useAuth"], "react": ["useContext", "useEffect"], - "/utils/context": ["EventLoopContext"], - "/utils/state": ["Event"], + "$/utils/context": ["EventLoopContext"], + "$/utils/state": ["Event"], } return addl_imports From 784b7654ba4bd23de845f7b093d7fb2b1ae2f1ba Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Wed, 8 Oct 2025 14:02:54 +0100 Subject: [PATCH 02/22] Refactor event handling in ClerkSessionSynchronizer to use ReflexEvent instead of Event --- custom_components/reflex_clerk_api/clerk_provider.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 4bbba71..09dc780 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -302,7 +302,7 @@ def add_imports( "@clerk/clerk-react": ["useAuth"], "react": ["useContext", "useEffect"], "$/utils/context": ["EventLoopContext"], - "$/utils/state": ["Event"], + "$/utils/state": ["ReflexEvent"], } return addl_imports @@ -319,10 +319,10 @@ def add_custom_code(self) -> list[str]: if (isLoaded && !!addEvents) { if (isSignedIn) { getToken().then(token => { - addEvents([Event("%s.set_clerk_session", {token})]) + addEvents([ReflexEvent("%s.set_clerk_session", {token})]) }) } else { - addEvents([Event("%s.clear_clerk_session")]) + addEvents([ReflexEvent("%s.clear_clerk_session")]) } } }, [isSignedIn]) From 704a419b6093f519871c42bf1158d24b6357db76 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Fri, 17 Oct 2025 08:39:23 +0100 Subject: [PATCH 03/22] Add organization management components: CreateOrganization, OrganizationProfile, OrganizationSwitcher, and OrganizationList This commit introduces four new components for managing organizations within the Clerk API. Each component includes customizable props for appearance, routing, and navigation after specific actions. The components are designed to enhance user interaction with organization creation, profile management, and switching between organizations. --- .../organization_components.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/custom_components/reflex_clerk_api/organization_components.py b/custom_components/reflex_clerk_api/organization_components.py index e69de29..d91337a 100644 --- a/custom_components/reflex_clerk_api/organization_components.py +++ b/custom_components/reflex_clerk_api/organization_components.py @@ -0,0 +1,132 @@ +from typing import Optional +from reflex_clerk_api.base import ClerkBase + + +class CreateOrganization(ClerkBase): + """ + The CreateOrganization component provides a form for users to create new organizations. + + This component renders Clerk's React component, + allowing users to set up new organizations with customizable appearance and routing. + + Props: + appearance: Optional object to style your components. Will only affect Clerk components. + path: The path where the component is mounted when routing is set to 'path'. + routing: The routing strategy for your pages. Defaults to 'path' for frameworks + that handle routing, or 'hash' for other SDKs. + after_create_organization_url: The full URL or path to navigate to after creating an organization. + fallback: An optional element to be rendered while the component is mounting. + """ + + tag = "CreateOrganization" + + # Optional props that CreateOrganization supports + appearance: Optional[str] = None + path: Optional[str] = None + routing: Optional[str] = None + after_create_organization_url: Optional[str] = None + fallback: Optional[str] = None + + +class OrganizationProfile(ClerkBase): + """ + The OrganizationProfile component allows users to manage their organization membership and security settings. + + This component renders Clerk's React component, + allowing users to manage organization information, members, billing, and security settings. + + Props: + appearance: Optional object to style your components. Will only affect Clerk components. + path: The path where the component is mounted when routing is set to 'path'. + routing: The routing strategy for your pages. Defaults to 'path' for frameworks + that handle routing, or 'hash' for other SDKs. + after_leave_organization_url: The full URL or path to navigate to after leaving an organization. + custom_pages: An array of custom pages to add to the organization profile. + fallback: An optional element to be rendered while the component is mounting. + """ + + tag = "OrganizationProfile" + + # Optional props that OrganizationProfile supports + appearance: Optional[str] = None + path: Optional[str] = None + routing: Optional[str] = None + after_leave_organization_url: Optional[str] = None + custom_pages: Optional[str] = None + fallback: Optional[str] = None + + +class OrganizationSwitcher(ClerkBase): + """ + The OrganizationSwitcher component displays the currently active organization and allows users to switch between organizations. + + This component renders Clerk's React component, + providing a dropdown interface for organization switching with customizable appearance. + + Props: + appearance: Optional object to style your components. Will only affect Clerk components. + organization_profile_mode: Controls whether selecting the organization opens as a modal or navigates to a page. + organization_profile_url: The full URL or path leading to the organization management interface. + create_organization_mode: Controls whether selecting create organization opens as a modal or navigates to a page. + create_organization_url: The full URL or path leading to the create organization interface. + after_leave_organization_url: The full URL or path to navigate to after leaving an organization. + after_create_organization_url: The full URL or path to navigate to after creating an organization. + after_select_organization_url: The full URL or path to navigate to after selecting an organization. + default_open: Controls whether the OrganizationSwitcher should open by default during the first render. + hide_personal_account: Controls whether the personal account option is hidden in the switcher. + fallback: An optional element to be rendered while the component is mounting. + """ + + tag = "OrganizationSwitcher" + + # Optional props that OrganizationSwitcher supports + appearance: Optional[str] = None + organization_profile_mode: Optional[str] = None + organization_profile_url: Optional[str] = None + create_organization_mode: Optional[str] = None + create_organization_url: Optional[str] = None + after_leave_organization_url: Optional[str] = None + after_create_organization_url: Optional[str] = None + after_select_organization_url: Optional[str] = None + default_open: Optional[str] = None + hide_personal_account: Optional[str] = None + fallback: Optional[str] = None + + +class OrganizationList(ClerkBase): + """ + The OrganizationList component displays a list of organizations that the user is a member of. + + This component renders Clerk's React component, + providing an interface to view and manage organization memberships. + + Props: + appearance: Optional object to style your components. Will only affect Clerk components. + after_create_organization_url: The full URL or path to navigate to after creating an organization. + after_select_organization_url: The full URL or path to navigate to after selecting an organization. + after_select_personal_url: The full URL or path to navigate to after selecting the personal account. + create_organization_mode: Controls whether selecting create organization opens as a modal or navigates to a page. + create_organization_url: The full URL or path leading to the create organization interface. + hide_personal_account: Controls whether the personal account option is hidden in the list. + skip_invitation_screen: Controls whether to skip the invitation screen when creating an organization. + fallback: An optional element to be rendered while the component is mounting. + """ + + tag = "OrganizationList" + + # Optional props that OrganizationList supports + appearance: Optional[str] = None + after_create_organization_url: Optional[str] = None + after_select_organization_url: Optional[str] = None + after_select_personal_url: Optional[str] = None + create_organization_mode: Optional[str] = None + create_organization_url: Optional[str] = None + hide_personal_account: Optional[str] = None + skip_invitation_screen: Optional[str] = None + fallback: Optional[str] = None + + +create_organization = CreateOrganization.create +organization_profile = OrganizationProfile.create +organization_switcher = OrganizationSwitcher.create +organization_list = OrganizationList.create From c90e2e3a4a528afadb1b6ee73476dbd37319e873 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Fri, 17 Oct 2025 14:17:14 +0100 Subject: [PATCH 04/22] Update __init__.py to include organization management components in the module exports --- custom_components/reflex_clerk_api/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/custom_components/reflex_clerk_api/__init__.py b/custom_components/reflex_clerk_api/__init__.py index 8b1c411..779a1c1 100644 --- a/custom_components/reflex_clerk_api/__init__.py +++ b/custom_components/reflex_clerk_api/__init__.py @@ -25,6 +25,12 @@ sign_up_button, ) from .user_components import user_button, user_profile +from .organization_components import ( + create_organization, + organization_profile, + organization_switcher, + organization_list, +) __all__ = [ "ClerkState", @@ -35,7 +41,11 @@ "clerk_loaded", "clerk_loading", "clerk_provider", + "create_organization", "on_load", + "organization_list", + "organization_profile", + "organization_switcher", "protect", "redirect_to_user_profile", "register_on_auth_change_handler", From 99c5a3d63f9f60612007fd2880a252c368136c15 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 18 Nov 2025 11:35:13 +0000 Subject: [PATCH 05/22] update license field for PEP621 --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eb94067..af0667b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,12 @@ path = "custom_components/reflex_clerk_api/__init__.py" name = "reflex-clerk-api" description = "Reflex custom component wrapping @clerk/clerk-react and integrating the clerk-backend-api" readme = "README.md" -license = "Apache-2.0" +license = { text = "Apache-2.0" } requires-python = ">=3.10" -authors = [{ name = "Tim Child", email = "timjchild@gmail.com" }] +authors = [ + { name = "Tim Child", email = "timjchild@gmail.com" }, + { name = "Paul Johnson", email = "paul.johnson@snaplabs.ai" } +] keywords = ["reflex","reflex-custom-components", "clerk", "clerk-backend-api"] dynamic = ["version"] From e1ab8836a5a897b2e27e8f04801cbe4848726a73 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Fri, 12 Dec 2025 09:53:09 +0000 Subject: [PATCH 06/22] Refactor OrganizationSwitcher and OrganizationList components to update prop names for clarity This commit renames the 'hide_personal_account' prop to 'hide_personal' for consistency and adds 'hide_slug' and 'organization_profile_props' to both components, enhancing their configurability for organization management. --- .../reflex_clerk_api/organization_components.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/custom_components/reflex_clerk_api/organization_components.py b/custom_components/reflex_clerk_api/organization_components.py index d91337a..047c8c1 100644 --- a/custom_components/reflex_clerk_api/organization_components.py +++ b/custom_components/reflex_clerk_api/organization_components.py @@ -73,7 +73,9 @@ class OrganizationSwitcher(ClerkBase): after_create_organization_url: The full URL or path to navigate to after creating an organization. after_select_organization_url: The full URL or path to navigate to after selecting an organization. default_open: Controls whether the OrganizationSwitcher should open by default during the first render. - hide_personal_account: Controls whether the personal account option is hidden in the switcher. + hide_personal: Controls whether the personal account option is hidden in the switcher. + hide_slug: Controls whether the optional slug field in the Organization creation screen is hidden. + organization_profile_props: Specify options for the underlying OrganizationProfile component. fallback: An optional element to be rendered while the component is mounting. """ @@ -89,7 +91,9 @@ class OrganizationSwitcher(ClerkBase): after_create_organization_url: Optional[str] = None after_select_organization_url: Optional[str] = None default_open: Optional[str] = None - hide_personal_account: Optional[str] = None + hide_personal: Optional[str] = None + hide_slug: Optional[str] = None + organization_profile_props: Optional[str] = None fallback: Optional[str] = None @@ -107,7 +111,8 @@ class OrganizationList(ClerkBase): after_select_personal_url: The full URL or path to navigate to after selecting the personal account. create_organization_mode: Controls whether selecting create organization opens as a modal or navigates to a page. create_organization_url: The full URL or path leading to the create organization interface. - hide_personal_account: Controls whether the personal account option is hidden in the list. + hide_personal: Controls whether the personal account option is hidden in the list. + hide_slug: Controls whether the optional slug field in the Organization creation screen is hidden. skip_invitation_screen: Controls whether to skip the invitation screen when creating an organization. fallback: An optional element to be rendered while the component is mounting. """ @@ -121,7 +126,8 @@ class OrganizationList(ClerkBase): after_select_personal_url: Optional[str] = None create_organization_mode: Optional[str] = None create_organization_url: Optional[str] = None - hide_personal_account: Optional[str] = None + hide_personal: Optional[str] = None + hide_slug: Optional[str] = None skip_invitation_screen: Optional[str] = None fallback: Optional[str] = None From 79e9b52149c5767bc1fd53df6135b69b13e93165 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Fri, 12 Dec 2025 10:01:16 +0000 Subject: [PATCH 07/22] Update clerk-backend-api dependency to version 4.0.0 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index af0667b..4c668a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dynamic = ["version"] dependencies = [ "authlib>=1.5.1,<2.0.0", - "clerk-backend-api>=2.0.0,<3.0.0", + "clerk-backend-api>=4.0.0", "reflex>=0.7.5", ] From a65e67667b3a815064fdf4d0c23961c45180f520 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Fri, 12 Dec 2025 10:26:14 +0000 Subject: [PATCH 08/22] Enhance organization components with updated prop types and additional options This commit refines the CreateOrganization, OrganizationProfile, OrganizationSwitcher, and OrganizationList components by updating prop types to use more specific types, including the addition of 'skip_invitation_screen' and 'hide_slug' options. These changes improve clarity and configurability for organization management within the Clerk API. --- .../organization_components.py | 190 ++++++++++-------- 1 file changed, 107 insertions(+), 83 deletions(-) diff --git a/custom_components/reflex_clerk_api/organization_components.py b/custom_components/reflex_clerk_api/organization_components.py index 047c8c1..bb1ced1 100644 --- a/custom_components/reflex_clerk_api/organization_components.py +++ b/custom_components/reflex_clerk_api/organization_components.py @@ -1,5 +1,7 @@ -from typing import Optional +import reflex as rx + from reflex_clerk_api.base import ClerkBase +from reflex_clerk_api.models import Appearance class CreateOrganization(ClerkBase): @@ -8,24 +10,30 @@ class CreateOrganization(ClerkBase): This component renders Clerk's React component, allowing users to set up new organizations with customizable appearance and routing. - - Props: - appearance: Optional object to style your components. Will only affect Clerk components. - path: The path where the component is mounted when routing is set to 'path'. - routing: The routing strategy for your pages. Defaults to 'path' for frameworks - that handle routing, or 'hash' for other SDKs. - after_create_organization_url: The full URL or path to navigate to after creating an organization. - fallback: An optional element to be rendered while the component is mounting. """ tag = "CreateOrganization" - # Optional props that CreateOrganization supports - appearance: Optional[str] = None - path: Optional[str] = None - routing: Optional[str] = None - after_create_organization_url: Optional[str] = None - fallback: Optional[str] = None + appearance: Appearance | None = None + "Optional object to style your components. Will only affect Clerk components." + + path: str | None = None + "The path where the component is mounted when routing is set to 'path'." + + routing: str | None = None + "The routing strategy for your pages. Defaults to 'path' for frameworks that handle routing, or 'hash' for other SDKs." + + after_create_organization_url: str | None = None + "The full URL or path to navigate to after creating an organization." + + skip_invitation_screen: bool | None = None + "Controls whether to skip the invitation screen when creating an organization." + + hide_slug: bool | None = None + "Controls whether the optional slug field in the Organization creation screen is hidden." + + fallback: rx.Component | None = None + "An optional element to be rendered while the component is mounting." class OrganizationProfile(ClerkBase): @@ -34,26 +42,27 @@ class OrganizationProfile(ClerkBase): This component renders Clerk's React component, allowing users to manage organization information, members, billing, and security settings. - - Props: - appearance: Optional object to style your components. Will only affect Clerk components. - path: The path where the component is mounted when routing is set to 'path'. - routing: The routing strategy for your pages. Defaults to 'path' for frameworks - that handle routing, or 'hash' for other SDKs. - after_leave_organization_url: The full URL or path to navigate to after leaving an organization. - custom_pages: An array of custom pages to add to the organization profile. - fallback: An optional element to be rendered while the component is mounting. """ tag = "OrganizationProfile" - # Optional props that OrganizationProfile supports - appearance: Optional[str] = None - path: Optional[str] = None - routing: Optional[str] = None - after_leave_organization_url: Optional[str] = None - custom_pages: Optional[str] = None - fallback: Optional[str] = None + appearance: Appearance | None = None + "Optional object to style your components. Will only affect Clerk components." + + path: str | None = None + "The path where the component is mounted when routing is set to 'path'." + + routing: str | None = None + "The routing strategy for your pages. Defaults to 'path' for frameworks that handle routing, or 'hash' for other SDKs." + + after_leave_organization_url: str | None = None + "The full URL or path to navigate to after leaving an organization." + + custom_pages: list | None = None + "An array of custom pages to add to the organization profile." + + fallback: rx.Component | None = None + "An optional element to be rendered while the component is mounting." class OrganizationSwitcher(ClerkBase): @@ -62,39 +71,48 @@ class OrganizationSwitcher(ClerkBase): This component renders Clerk's React component, providing a dropdown interface for organization switching with customizable appearance. - - Props: - appearance: Optional object to style your components. Will only affect Clerk components. - organization_profile_mode: Controls whether selecting the organization opens as a modal or navigates to a page. - organization_profile_url: The full URL or path leading to the organization management interface. - create_organization_mode: Controls whether selecting create organization opens as a modal or navigates to a page. - create_organization_url: The full URL or path leading to the create organization interface. - after_leave_organization_url: The full URL or path to navigate to after leaving an organization. - after_create_organization_url: The full URL or path to navigate to after creating an organization. - after_select_organization_url: The full URL or path to navigate to after selecting an organization. - default_open: Controls whether the OrganizationSwitcher should open by default during the first render. - hide_personal: Controls whether the personal account option is hidden in the switcher. - hide_slug: Controls whether the optional slug field in the Organization creation screen is hidden. - organization_profile_props: Specify options for the underlying OrganizationProfile component. - fallback: An optional element to be rendered while the component is mounting. """ tag = "OrganizationSwitcher" - # Optional props that OrganizationSwitcher supports - appearance: Optional[str] = None - organization_profile_mode: Optional[str] = None - organization_profile_url: Optional[str] = None - create_organization_mode: Optional[str] = None - create_organization_url: Optional[str] = None - after_leave_organization_url: Optional[str] = None - after_create_organization_url: Optional[str] = None - after_select_organization_url: Optional[str] = None - default_open: Optional[str] = None - hide_personal: Optional[str] = None - hide_slug: Optional[str] = None - organization_profile_props: Optional[str] = None - fallback: Optional[str] = None + appearance: Appearance | None = None + "Optional object to style your components. Will only affect Clerk components." + + organization_profile_mode: str | None = None + "Controls whether selecting the organization opens as a modal or navigates to a page. Defaults to 'modal'." + + organization_profile_url: str | None = None + "The full URL or path leading to the organization management interface." + + organization_profile_props: dict | None = None + "Specify options for the underlying OrganizationProfile component." + + create_organization_mode: str | None = None + "Controls whether selecting create organization opens as a modal or navigates to a page. Defaults to 'modal'." + + create_organization_url: str | None = None + "The full URL or path leading to the create organization interface." + + after_leave_organization_url: str | None = None + "The full URL or path to navigate to after leaving an organization." + + after_create_organization_url: str | None = None + "The full URL or path to navigate to after creating an organization." + + after_select_organization_url: str | None = None + "The full URL or path to navigate to after selecting an organization." + + default_open: bool | None = None + "Controls whether the OrganizationSwitcher should open by default during the first render." + + hide_personal: bool | None = None + "Controls whether the personal account option is hidden in the switcher." + + hide_slug: bool | None = None + "Controls whether the optional slug field in the Organization creation screen is hidden." + + fallback: rx.Component | None = None + "An optional element to be rendered while the component is mounting." class OrganizationList(ClerkBase): @@ -103,33 +121,39 @@ class OrganizationList(ClerkBase): This component renders Clerk's React component, providing an interface to view and manage organization memberships. - - Props: - appearance: Optional object to style your components. Will only affect Clerk components. - after_create_organization_url: The full URL or path to navigate to after creating an organization. - after_select_organization_url: The full URL or path to navigate to after selecting an organization. - after_select_personal_url: The full URL or path to navigate to after selecting the personal account. - create_organization_mode: Controls whether selecting create organization opens as a modal or navigates to a page. - create_organization_url: The full URL or path leading to the create organization interface. - hide_personal: Controls whether the personal account option is hidden in the list. - hide_slug: Controls whether the optional slug field in the Organization creation screen is hidden. - skip_invitation_screen: Controls whether to skip the invitation screen when creating an organization. - fallback: An optional element to be rendered while the component is mounting. """ tag = "OrganizationList" - # Optional props that OrganizationList supports - appearance: Optional[str] = None - after_create_organization_url: Optional[str] = None - after_select_organization_url: Optional[str] = None - after_select_personal_url: Optional[str] = None - create_organization_mode: Optional[str] = None - create_organization_url: Optional[str] = None - hide_personal: Optional[str] = None - hide_slug: Optional[str] = None - skip_invitation_screen: Optional[str] = None - fallback: Optional[str] = None + appearance: Appearance | None = None + "Optional object to style your components. Will only affect Clerk components." + + after_create_organization_url: str | None = None + "The full URL or path to navigate to after creating an organization." + + after_select_organization_url: str | None = None + "The full URL or path to navigate to after selecting an organization." + + after_select_personal_url: str | None = None + "The full URL or path to navigate to after selecting the personal account." + + create_organization_mode: str | None = None + "Controls whether selecting create organization opens as a modal or navigates to a page. Defaults to 'modal'." + + create_organization_url: str | None = None + "The full URL or path leading to the create organization interface." + + hide_personal: bool | None = None + "Controls whether the personal account option is hidden in the list." + + hide_slug: bool | None = None + "Controls whether the optional slug field in the Organization creation screen is hidden." + + skip_invitation_screen: bool | None = None + "Controls whether to skip the invitation screen when creating an organization." + + fallback: rx.Component | None = None + "An optional element to be rendered while the component is mounting." create_organization = CreateOrganization.create From 1405202c4df9ae29e0feb72589929d675fe83f04 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Fri, 12 Dec 2025 10:33:16 +0000 Subject: [PATCH 09/22] Update authlib dependency in pyproject.toml to remove upper version constraint --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4c668a8..65b5299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ keywords = ["reflex","reflex-custom-components", "clerk", "clerk-backend-api"] dynamic = ["version"] dependencies = [ - "authlib>=1.5.1,<2.0.0", + "authlib>=1.5.1", "clerk-backend-api>=4.0.0", "reflex>=0.7.5", ] From d6329dec0175bffaa7010aaf1d3514685d091d06 Mon Sep 17 00:00:00 2001 From: uzair-snap Date: Fri, 9 Jan 2026 18:36:14 +0000 Subject: [PATCH 10/22] fix: resolve auth timeout and ExpiredTokenError in multi-worker environments Frontend (ClerkSessionSynchronizer): - Fix useEffect deps: [isSignedIn] -> [isLoaded, isSignedIn, addEvents, getToken] - Add reconnect-safe resend via dedupe guard keyed on (stateKey, addEvents identity) - Request fresh token with skipCache:true to avoid near-expiry tokens - Clear session on token fetch failure to prevent stuck auth_checked=false Backend (ClerkState.set_clerk_session): - Add 60s leeway to JWT validation for clock skew tolerance - Catch ExpiredTokenError (in addition to existing InvalidClaimError/MissingClaimError) - Return clear_clerk_session on validation failure instead of raising - Add set_jwt_validate_leeway_seconds() for downstream configuration Tests: - Add unit test for ExpiredTokenError -> clear session path - Add JS string assertion for correct deps array and skipCache Fixes: Authentication timeout after max attempts, auth not ready after timeout, ExpiredTokenError crashes in set_clerk_session --- .../reflex_clerk_api/clerk_provider.py | 67 +++++++++++++++---- tests/test_clerk_provider_unit.py | 57 ++++++++++++++++ 2 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 tests/test_clerk_provider_unit.py diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 09dc780..b1397c2 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -58,6 +58,8 @@ class ClerkState(rx.State): "nbf": {"essential": True}, # "azp": {"essential": False, "values": ["http://localhost:3000", "https://example.com"]}, } + _jwt_validate_leeway_seconds: ClassVar[int] = 60 + """Clock-skew leeway (seconds) for validating JWT claims like exp/nbf.""" @classmethod def register_dependent_handler(cls, handler: EventCallback) -> None: @@ -84,6 +86,15 @@ def set_claims_options(cls, claims_options: dict[str, Any]) -> None: """Set the claims options for the JWT claims validation.""" cls._claims_options = claims_options + @classmethod + def set_jwt_validate_leeway_seconds(cls, seconds: int) -> None: + """Set clock-skew leeway (seconds) for JWT exp/nbf validation. + + Default is 60 seconds. Increase if you see intermittent ExpiredTokenError + due to clock drift between Clerk servers and your backend. + """ + cls._jwt_validate_leeway_seconds = seconds + @property def client(self) -> clerk_backend_api.Clerk: if self._client is None: @@ -115,9 +126,13 @@ async def set_clerk_session(self, token: str) -> EventType: return ClerkState.clear_clerk_session try: # Validate the token according to the claim options (e.g. iss, exp, nbf, azp.) - decoded.validate() - except (jose_errors.InvalidClaimError, jose_errors.MissingClaimError) as e: - logging.warning(f"JWT token is invalid: {e}") + decoded.validate(leeway=self._jwt_validate_leeway_seconds) + except ( + jose_errors.ExpiredTokenError, + jose_errors.InvalidClaimError, + jose_errors.MissingClaimError, + ) as e: + logging.warning(f"JWT token validation failed: {type(e).__name__}: {e}") return ClerkState.clear_clerk_session async with self: @@ -300,7 +315,7 @@ def add_imports( ) -> rx.ImportDict: addl_imports: rx.ImportDict = { "@clerk/clerk-react": ["useAuth"], - "react": ["useContext", "useEffect"], + "react": ["useContext", "useEffect", "useRef"], "$/utils/context": ["EventLoopContext"], "$/utils/state": ["ReflexEvent"], } @@ -313,26 +328,50 @@ def add_custom_code(self) -> list[str]: """ function ClerkSessionSynchronizer({ children }) { const { getToken, isLoaded, isSignedIn } = useAuth() - const [ addEvents, connectErrors ] = useContext(EventLoopContext) + const [ addEvents ] = useContext(EventLoopContext) + const lastSentRef = useRef({ stateKey: null, addEvents: null }) useEffect(() => { - if (isLoaded && !!addEvents) { - if (isSignedIn) { - getToken().then(token => { - addEvents([ReflexEvent("%s.set_clerk_session", {token})]) + // Wait for all dependencies to be ready. + if (!isLoaded || !addEvents) return + + // Deduplicate rapid calls, but remain reconnect-safe: + // addEvents identity changes across websocket reconnects, so include it in the key. + const stateKey = isSignedIn ? "signed_in" : "signed_out" + if ( + lastSentRef.current?.stateKey === stateKey && + lastSentRef.current?.addEvents === addEvents + ) return + lastSentRef.current = { stateKey, addEvents } + + if (isSignedIn) { + // Prefer a fresh token; cached tokens can be close to expiry. + // If this Clerk version doesn't support skipCache, fall back to the default call. + Promise.resolve() + .then(() => getToken({ skipCache: true })) + .catch(() => getToken()) + .then(token => { + if (token) { + addEvents([ReflexEvent("%s.set_clerk_session", {token})]) + } else { + // Token unavailable despite isSignedIn - clear to avoid stuck auth state. + addEvents([ReflexEvent("%s.clear_clerk_session")]) + } + }).catch(() => { + // Token retrieval failed - clear to avoid stuck auth state. + addEvents([ReflexEvent("%s.clear_clerk_session")]) }) - } else { - addEvents([ReflexEvent("%s.clear_clerk_session")]) - } + } else { + addEvents([ReflexEvent("%s.clear_clerk_session")]) } - }, [isSignedIn]) + }, [isLoaded, isSignedIn, addEvents, getToken]) return ( <>{children} ) } """ - % (clerk_state_name, clerk_state_name) + % (clerk_state_name, clerk_state_name, clerk_state_name, clerk_state_name) ] diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py new file mode 100644 index 0000000..7472f60 --- /dev/null +++ b/tests/test_clerk_provider_unit.py @@ -0,0 +1,57 @@ +import asyncio +import sys +from pathlib import Path + +import authlib.jose.errors as jose_errors + + +# Ensure tests use the local custom component code (not an installed wheel). +_CUSTOM_COMPONENTS_DIR = Path(__file__).resolve().parents[1] / "custom_components" +sys.path.insert(0, str(_CUSTOM_COMPONENTS_DIR)) + + +def test_set_clerk_session_expired_token_clears(monkeypatch): + """Expired tokens should not crash the handler; they should clear session.""" + # Import inside the test so the module is importable in different test layouts. + from reflex_clerk_api.clerk_provider import ClerkState + + import importlib + + clerk_provider_module = importlib.import_module("reflex_clerk_api.clerk_provider") + + # Instantiate state in a framework-safe way for tests. + state = ClerkState(_reflex_internal_init=True) + + async def fake_get_jwk_keys(self): + return {} + + monkeypatch.setattr(ClerkState, "_get_jwk_keys", fake_get_jwk_keys, raising=True) + + validate_calls: dict[str, object] = {} + + class FakeClaims: + def validate(self, leeway=None): + validate_calls["leeway"] = leeway + raise jose_errors.ExpiredTokenError() + + monkeypatch.setattr( + clerk_provider_module.jwt, + "decode", + lambda *args, **kwargs: FakeClaims(), + raising=True, + ) + + result = asyncio.run(ClerkState.set_clerk_session.fn(state, token="fake")) + assert validate_calls["leeway"] == 60 + assert result == ClerkState.clear_clerk_session + + +def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcache(): + """String-based regression test for the generated JS.""" + from reflex_clerk_api.clerk_provider import ClerkSessionSynchronizer + + js = ClerkSessionSynchronizer.create().add_custom_code()[0] + assert "[isLoaded, isSignedIn, addEvents, getToken]" in js + assert "skipCache: true" in js + + From c5544743bc44977fe6a5fb7ef21bb67ca66214e1 Mon Sep 17 00:00:00 2001 From: uzair-snap Date: Mon, 12 Jan 2026 09:13:04 +0000 Subject: [PATCH 11/22] Enhance JWT validation and ClerkSessionSynchronizer functionality Backend (ClerkState): - Add validation for jwt_validate_leeway_seconds to ensure it is a non-negative integer and does not exceed 3600 seconds. Frontend (ClerkSessionSynchronizer): - Update function syntax to use double curly braces for JSX expressions. - Refactor event handling to ensure proper session clearing on token retrieval failures. Tests: - Adjust imports for better module accessibility in tests. These changes improve error handling and maintainability in the authentication process. --- .../reflex_clerk_api/clerk_provider.py | 61 +++++++++++-------- tests/test_clerk_provider_unit.py | 8 +-- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index b1397c2..764c562 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -92,7 +92,21 @@ def set_jwt_validate_leeway_seconds(cls, seconds: int) -> None: Default is 60 seconds. Increase if you see intermittent ExpiredTokenError due to clock drift between Clerk servers and your backend. + + Args: + seconds: Non-negative integer, max 3600 (1 hour). + + Raises: + ValueError: If seconds is negative or exceeds 3600. """ + if not isinstance(seconds, int) or seconds < 0: + raise ValueError( + f"jwt_validate_leeway_seconds must be a non-negative integer, got {seconds!r}" + ) + if seconds > 3600: + raise ValueError( + f"jwt_validate_leeway_seconds exceeds maximum of 3600 (1 hour), got {seconds}" + ) cls._jwt_validate_leeway_seconds = seconds @property @@ -326,12 +340,12 @@ def add_custom_code(self) -> list[str]: return [ """ -function ClerkSessionSynchronizer({ children }) { - const { getToken, isLoaded, isSignedIn } = useAuth() +function ClerkSessionSynchronizer({{ children }}) {{ + const {{ getToken, isLoaded, isSignedIn }} = useAuth() const [ addEvents ] = useContext(EventLoopContext) - const lastSentRef = useRef({ stateKey: null, addEvents: null }) + const lastSentRef = useRef({{ stateKey: null, addEvents: null }}) - useEffect(() => { + useEffect(() => {{ // Wait for all dependencies to be ready. if (!isLoaded || !addEvents) return @@ -342,36 +356,35 @@ def add_custom_code(self) -> list[str]: lastSentRef.current?.stateKey === stateKey && lastSentRef.current?.addEvents === addEvents ) return - lastSentRef.current = { stateKey, addEvents } + lastSentRef.current = {{ stateKey, addEvents }} - if (isSignedIn) { + if (isSignedIn) {{ // Prefer a fresh token; cached tokens can be close to expiry. // If this Clerk version doesn't support skipCache, fall back to the default call. Promise.resolve() - .then(() => getToken({ skipCache: true })) + .then(() => getToken({{ skipCache: true }})) .catch(() => getToken()) - .then(token => { - if (token) { - addEvents([ReflexEvent("%s.set_clerk_session", {token})]) - } else { + .then(token => {{ + if (token) {{ + addEvents([ReflexEvent("{state}.set_clerk_session", {{token}})]) + }} else {{ // Token unavailable despite isSignedIn - clear to avoid stuck auth state. - addEvents([ReflexEvent("%s.clear_clerk_session")]) - } - }).catch(() => { + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }} + }}).catch(() => {{ // Token retrieval failed - clear to avoid stuck auth state. - addEvents([ReflexEvent("%s.clear_clerk_session")]) - }) - } else { - addEvents([ReflexEvent("%s.clear_clerk_session")]) - } - }, [isLoaded, isSignedIn, addEvents, getToken]) + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }}) + }} else {{ + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }} + }}, [isLoaded, isSignedIn, addEvents, getToken]) return ( - <>{children} + <>{{children}} ) -} -""" - % (clerk_state_name, clerk_state_name, clerk_state_name, clerk_state_name) +}} +""".format(state=clerk_state_name) ] diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py index 7472f60..a3257c0 100644 --- a/tests/test_clerk_provider_unit.py +++ b/tests/test_clerk_provider_unit.py @@ -13,11 +13,13 @@ def test_set_clerk_session_expired_token_clears(monkeypatch): """Expired tokens should not crash the handler; they should clear session.""" # Import inside the test so the module is importable in different test layouts. - from reflex_clerk_api.clerk_provider import ClerkState - + # We need the actual module object (not just ClerkState) to monkeypatch jwt.decode + # where it's used. importlib is required because reflex_clerk_api.clerk_provider + # resolves to the function via __init__.py re-exports. import importlib clerk_provider_module = importlib.import_module("reflex_clerk_api.clerk_provider") + from reflex_clerk_api.clerk_provider import ClerkState # Instantiate state in a framework-safe way for tests. state = ClerkState(_reflex_internal_init=True) @@ -53,5 +55,3 @@ def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcach js = ClerkSessionSynchronizer.create().add_custom_code()[0] assert "[isLoaded, isSignedIn, addEvents, getToken]" in js assert "skipCache: true" in js - - From 40d6fbc5616307ae422342d0e57661b2995d8ecd Mon Sep 17 00:00:00 2001 From: uzair-snap Date: Mon, 12 Jan 2026 09:22:07 +0000 Subject: [PATCH 12/22] Update pytest configuration and enhance JWT validation - Added 'pythonpath' to pytest configuration in pyproject.toml to include custom components. - Improved validation in ClerkState to ensure 'jwt_validate_leeway_seconds' is a non-negative integer and not a boolean. - Cleaned up test imports by removing unnecessary path adjustments for better module accessibility. These changes streamline testing and enhance error handling in JWT validation. --- custom_components/reflex_clerk_api/clerk_provider.py | 2 +- pyproject.toml | 1 + tests/test_clerk_provider_unit.py | 7 ------- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 764c562..14950d5 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -99,7 +99,7 @@ def set_jwt_validate_leeway_seconds(cls, seconds: int) -> None: Raises: ValueError: If seconds is negative or exceeds 3600. """ - if not isinstance(seconds, int) or seconds < 0: + if not isinstance(seconds, int) or isinstance(seconds, bool) or seconds < 0: raise ValueError( f"jwt_validate_leeway_seconds must be a non-negative integer, got {seconds!r}" ) diff --git a/pyproject.toml b/pyproject.toml index 65b5299..74eaa3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ homepage = "https://reflex-clerk-api-demo.adventuresoftim.com" [tool.pytest.ini_options] # addopts = "--headed" +pythonpath = ["custom_components"] [tool.pyright] venvPath = "." diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py index a3257c0..8a704c7 100644 --- a/tests/test_clerk_provider_unit.py +++ b/tests/test_clerk_provider_unit.py @@ -1,15 +1,8 @@ import asyncio -import sys -from pathlib import Path import authlib.jose.errors as jose_errors -# Ensure tests use the local custom component code (not an installed wheel). -_CUSTOM_COMPONENTS_DIR = Path(__file__).resolve().parents[1] / "custom_components" -sys.path.insert(0, str(_CUSTOM_COMPONENTS_DIR)) - - def test_set_clerk_session_expired_token_clears(monkeypatch): """Expired tokens should not crash the handler; they should clear session.""" # Import inside the test so the module is importable in different test layouts. From 5bb9e656cc8a6ce17ad1a430f4cffdcfeb2616a1 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 7 Apr 2026 14:17:06 +0100 Subject: [PATCH 13/22] chore: Update clerk-backend-api dependency to version 5.0.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 785aa79..0c77c2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dynamic = ["version"] dependencies = [ "authlib>=1.5.1", - "clerk-backend-api>=4.0.0", + "clerk-backend-api>=5.0.0", "reflex>=0.8.0", ] From 91e94128c6a2311d514ea4c9551bf1bd5f799846 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 7 Apr 2026 15:08:33 +0100 Subject: [PATCH 14/22] feat: add Clerk Show and PricingTable wrappers --- .../reflex_clerk_api/__init__.py | 4 ++ .../reflex_clerk_api/billing_components.py | 20 ++++++++ .../reflex_clerk_api/control_components.py | 19 ++++++++ tests/test_pricing_table_unit.py | 39 +++++++++++++++ tests/test_show_unit.py | 47 +++++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 custom_components/reflex_clerk_api/billing_components.py create mode 100644 tests/test_pricing_table_unit.py create mode 100644 tests/test_show_unit.py diff --git a/custom_components/reflex_clerk_api/__init__.py b/custom_components/reflex_clerk_api/__init__.py index 71fa93d..02dec52 100644 --- a/custom_components/reflex_clerk_api/__init__.py +++ b/custom_components/reflex_clerk_api/__init__.py @@ -17,7 +17,9 @@ redirect_to_user_profile, signed_in, signed_out, + show, ) +from .billing_components import pricing_table from .pages import add_sign_in_page, add_sign_up_page from .unstyled_components import ( SignInButton, @@ -48,6 +50,7 @@ "organization_profile", "organization_switcher", "protect", + "pricing_table", "redirect_to_user_profile", "register_on_auth_change_handler", "sign_in", @@ -55,6 +58,7 @@ "sign_out_button", "sign_up", "sign_up_button", + "show", "signed_in", "signed_out", "update_user_phone_number", diff --git a/custom_components/reflex_clerk_api/billing_components.py b/custom_components/reflex_clerk_api/billing_components.py new file mode 100644 index 0000000..841c120 --- /dev/null +++ b/custom_components/reflex_clerk_api/billing_components.py @@ -0,0 +1,20 @@ +import reflex as rx + +from reflex_clerk_api.base import ClerkBase +from reflex_clerk_api.models import Appearance + + +class PricingTable(ClerkBase): + tag = "PricingTable" + + _rename_props: dict[str, str] = {"for_": "for"} + + for_: str | None = None + collapse_features: bool | None = None + cta_position: str | None = None + fallback: rx.Component | None = None + new_subscription_redirect_url: str | None = None + appearance: Appearance | None = None + + +pricing_table = PricingTable.create diff --git a/custom_components/reflex_clerk_api/control_components.py b/custom_components/reflex_clerk_api/control_components.py index a775cdd..7801340 100644 --- a/custom_components/reflex_clerk_api/control_components.py +++ b/custom_components/reflex_clerk_api/control_components.py @@ -1,3 +1,5 @@ +import reflex as rx + from reflex_clerk_api.base import ClerkBase Javascript = str @@ -31,6 +33,22 @@ class Protect(ClerkBase): "Optional string corresponding to an Organization's Role in the format org:" +class Show(ClerkBase): + tag = "Show" + + when: dict | str | None = None + ( + "The condition to evaluate. Supports 'signed-in' / 'signed-out' strings and " + "object checks like {'feature': '...'} or {'plan': '...'}." + ) + # Known limitation: callback-style `when=(has) => ...` is not supported with the + # current str/dict prop typing and would serialize as a quoted string literal. + fallback: rx.Component | None = None + "Optional UI to render if the condition fails." + treat_pending_as_signed_out: bool | None = None + "Whether pending sessions are treated as signed out. Defaults to true in Clerk." + + class RedirectToSignIn(ClerkBase): """Immediately redirects the user to the sign in page when rendered.""" @@ -86,6 +104,7 @@ class SignedOut(ClerkBase): clerk_loaded = ClerkLoaded.create clerk_loading = ClerkLoading.create protect = Protect.create +show = Show.create redirect_to_sign_in = RedirectToSignIn.create redirect_to_sign_up = RedirectToSignUp.create redirect_to_user_profile = RedirectToUserProfile.create diff --git a/tests/test_pricing_table_unit.py b/tests/test_pricing_table_unit.py new file mode 100644 index 0000000..61eca48 --- /dev/null +++ b/tests/test_pricing_table_unit.py @@ -0,0 +1,39 @@ +from reflex_clerk_api.billing_components import PricingTable + + +def _render_props(component: PricingTable) -> list[str]: + return component.render()["props"] + + +def test_pricing_table_create_with_no_props_renders_pricing_table_tag(): + component = PricingTable.create() + + rendered = component.render() + assert rendered["name"] == "PricingTable" + assert rendered["props"] == [] + + +def test_pricing_table_create_for_renames_to_for(): + component = PricingTable.create(for_="organization") + + props = _render_props(component) + assert 'for:"organization"' in props + assert all(not prop.startswith("for_:") for prop in props) + + +def test_pricing_table_create_with_collapse_features(): + component = PricingTable.create(collapse_features=True) + + props = _render_props(component) + assert "collapseFeatures:true" in props + + +def test_pricing_table_create_with_new_subscription_redirect_url(): + component = PricingTable.create(new_subscription_redirect_url="/billing") + + props = _render_props(component) + assert 'newSubscriptionRedirectUrl:"/billing"' in props + + +def test_pricing_table_rename_props_maps_for__to_for(): + assert PricingTable._rename_props["for_"] == "for" diff --git a/tests/test_show_unit.py b/tests/test_show_unit.py new file mode 100644 index 0000000..250ebcf --- /dev/null +++ b/tests/test_show_unit.py @@ -0,0 +1,47 @@ +from reflex_clerk_api.control_components import Show + + +def _render_props(component: Show) -> list[str]: + return component.render()["props"] + + +def test_show_create_with_no_props_renders_show_tag(): + component = Show.create() + + rendered = component.render() + assert rendered["name"] == "Show" + assert rendered["props"] == [] + + +def test_show_create_with_signed_in_string_when(): + component = Show.create(when="signed-in") + + props = _render_props(component) + assert 'when:"signed-in"' in props + + +def test_show_create_with_feature_when_serializes_to_js_object(): + component = Show.create(when={"feature": "premium"}) + + props = _render_props(component) + when_prop = next(prop for prop in props if prop.startswith("when:")) + assert when_prop.startswith("when:({") + assert '["feature"] : "premium"' in when_prop + assert 'when:"{' not in when_prop + + +def test_show_create_with_plan_when_serializes_to_js_object(): + component = Show.create(when={"plan": "pro"}) + + props = _render_props(component) + when_prop = next(prop for prop in props if prop.startswith("when:")) + assert when_prop.startswith("when:({") + assert '["plan"] : "pro"' in when_prop + assert 'when:"{' not in when_prop + + +def test_show_create_with_treat_pending_as_signed_out(): + component = Show.create(treat_pending_as_signed_out=True) + + props = _render_props(component) + assert "treatPendingAsSignedOut:true" in props From 97ff05284de05263b59eac520b51ed0ccf6579bc Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 7 Apr 2026 16:56:12 +0100 Subject: [PATCH 15/22] fix: switch Clerk component library to @clerk/react --- custom_components/reflex_clerk_api/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/reflex_clerk_api/base.py b/custom_components/reflex_clerk_api/base.py index 00c9305..9ea088d 100644 --- a/custom_components/reflex_clerk_api/base.py +++ b/custom_components/reflex_clerk_api/base.py @@ -3,4 +3,5 @@ class ClerkBase(rx.Component): # The React library to wrap. - library = "@clerk/clerk-react" + # `Show` is exported from `@clerk/react` (v6+), not `@clerk/clerk-react`. + library = "@clerk/react@^6.2.0" From 1ffab9302d84c104c76257505913593fb0bbdf0f Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 7 Apr 2026 17:11:47 +0100 Subject: [PATCH 16/22] fix: import useAuth from @clerk/react --- custom_components/reflex_clerk_api/clerk_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 0b0da98..3c56909 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -392,7 +392,7 @@ def add_imports( self, ) -> rx.ImportDict: addl_imports: rx.ImportDict = { - "@clerk/clerk-react": ["useAuth"], + "@clerk/react": ["useAuth"], "react": ["useContext", "useEffect", "useRef"], "$/utils/context": ["EventLoopContext"], "$/utils/state": ["ReflexEvent"], From 335bb947b7096e09c13144ae383ff37600165439 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 7 Apr 2026 17:24:34 +0100 Subject: [PATCH 17/22] feat: add ClerkProvider ui pin support --- .../reflex_clerk_api/clerk_provider.py | 11 ++++++++++ tests/test_clerk_provider_unit.py | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 3c56909..d73f63c 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -12,6 +12,7 @@ from authlib.jose import JWTClaims, jwt from reflex.event import EventCallback, EventType, IndividualEventType from reflex.utils.exceptions import ImmutableStateError +from reflex.vars.base import Var from reflex_clerk_api.base import ClerkBase @@ -484,6 +485,9 @@ class ClerkProvider(ClerkBase): # trigger to what will be passed to the backend event handler function. # on_change: rx.EventHandler[lambda e: [e]] + ui: Any | None = None + """UI package pin for Clerk components (passed as ui={ui} from @clerk/ui).""" + after_multi_session_single_sign_out_url: str = "" """The URL to navigate to after a successful sign-out from multiple sessions.""" @@ -586,8 +590,15 @@ class ClerkProvider(ClerkBase): waitlist_url: str = "" """The full URL or path to the waitlist page.""" + def add_imports(self) -> rx.ImportDict: + # Import ui package to pin Clerk component versions when using structural CSS. + return {"@clerk/ui": ["ui"]} + @classmethod def create(cls, *children, **props) -> Self: + # Default to ui={ui} unless caller explicitly supplies a different ui config. + if "ui" not in props: + props["ui"] = Var(_js_expr="ui", _var_type=Any) return cast(Self, super().create(*children, **props)) def add_custom_code(self) -> list[str]: diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py index 8a704c7..1f21156 100644 --- a/tests/test_clerk_provider_unit.py +++ b/tests/test_clerk_provider_unit.py @@ -48,3 +48,24 @@ def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcach js = ClerkSessionSynchronizer.create().add_custom_code()[0] assert "[isLoaded, isSignedIn, addEvents, getToken]" in js assert "skipCache: true" in js + + +def test_clerk_provider_adds_clerk_ui_import_by_default(): + from reflex_clerk_api.clerk_provider import ClerkProvider + + imports = ClerkProvider.create().add_imports() + assert imports.get("@clerk/ui") == ["ui"] + + +def test_clerk_provider_defaults_ui_prop_to_imported_ui_symbol(): + from reflex_clerk_api.clerk_provider import ClerkProvider + + props = ClerkProvider.create().render()["props"] + assert "ui:ui" in props + + +def test_clerk_provider_allows_ui_override(): + from reflex_clerk_api.clerk_provider import ClerkProvider + + props = ClerkProvider.create(ui="custom-ui").render()["props"] + assert 'ui:"custom-ui"' in props From f3c35e357ec36771aff48f5f361ea12f090dec4b Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 7 Apr 2026 17:36:02 +0100 Subject: [PATCH 18/22] refactor: remove deprecated control wrappers --- README.md | 8 ++-- .../clerk_api_demo/clerk_api_demo.py | 41 +++++++++++-------- .../reflex_clerk_api/__init__.py | 6 --- .../reflex_clerk_api/control_components.py | 30 -------------- docs/about.md | 4 +- docs/features.md | 3 +- docs/getting_started.md | 10 +++-- pyproject.toml | 2 +- tests/test_public_api_exports.py | 11 +++++ 9 files changed, 51 insertions(+), 64 deletions(-) create mode 100644 tests/test_public_api_exports.py diff --git a/README.md b/README.md index 8ce53c7..9900a00 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,15 @@ def index() -> rx.Component: return clerk.clerk_provider( rx.container( clerk.clerk_loaded( - clerk.signed_in( + clerk.show( clerk.sign_on( rx.button("Sign out"), ), + when="signed-in", ), - clerk.signed_out( + clerk.show( rx.button("Sign in"), + when="signed-out", ), ), ), @@ -69,4 +71,4 @@ I use [Taskfile](https://taskfile.dev/) (similar to `makefile`) to make common t ## TODO: -- How should the `condition` and `fallback` props be defined on `Protect`? They are supposed to be `Javascript` and `JSX` respectively, but are just `str` for now... Is `Javascript` `rx.Script`? And `JSX` `rx.Component`? +- Add migration notes for deprecated wrappers removed in the next major release. diff --git a/clerk_api_demo/clerk_api_demo/clerk_api_demo.py b/clerk_api_demo/clerk_api_demo/clerk_api_demo.py index 4a07c30..67cbf9d 100644 --- a/clerk_api_demo/clerk_api_demo/clerk_api_demo.py +++ b/clerk_api_demo/clerk_api_demo/clerk_api_demo.py @@ -85,8 +85,8 @@ def demo_page_header_and_description() -> rx.Component: rx.link(rx.code("reflex"), href="https://reflex.dev"), "components that wrap Clerk react components (", rx.link( - rx.code("@clerk/clerk-react"), - href="https://www.npmjs.com/package/@clerk/clerk-react", + rx.code("@clerk/react"), + href="https://www.npmjs.com/package/@clerk/react", ), ") and interact with the Clerk backend API.", size="4", @@ -192,13 +192,15 @@ def getting_started() -> rx.Component: def index() -> rx.Component: return clerk.clerk_provider( clerk.clerk_loaded( - clerk.signed_in( + clerk.show( clerk.sign_on( rx.button("Sign out"), ), + when="signed-in", ), - clerk.signed_out( + clerk.show( rx.button("Sign in"), + when="signed-out", ), ), publishable_key=os.environ["CLERK_PUBLISHABLE_KEY"], @@ -439,9 +441,10 @@ def clerk_loaded_demo() -> rx.Component: rx.vstack( rx.text("You'll only see content below if you are signed in"), rx.divider(), - clerk.signed_in( + clerk.show( rx.text("You are signed in.", data_testid="you_are_signed_in"), clerk.sign_out_button(rx.button("Sign out", width="100%")), + when="signed-in", ), ) ) @@ -449,9 +452,10 @@ def clerk_loaded_demo() -> rx.Component: rx.vstack( rx.text("You'll only see content below if you are signed out"), rx.divider(), - clerk.signed_out( + clerk.show( rx.text("You are signed out.", data_testid="you_are_signed_out"), clerk.sign_in_button(rx.button("Sign in", width="100%")), + when="signed-out", ), ) ) @@ -477,7 +481,7 @@ def clerk_loaded_demo() -> rx.Component: return demo_card( "Clerk loaded and signed in/out areas", rx.markdown( - "Demo of `clerk_loaded`, `clerk_loading`, and `signed_in`, `signed_out` components." + "Demo of `clerk_loaded`, `clerk_loading`, and `show` components." ), demo, ) @@ -496,18 +500,20 @@ def links_to_demo_pages() -> rx.Component: But, you can also create your own with more customization.""") ), - clerk.signed_out( + clerk.show( rx.grid( rx.link(rx.button("Go to sign up page", width="100%"), href="/sign-up"), rx.link(rx.button("Go to sign in page", width="100%"), href="/sign-in"), width="100%", columns="2", spacing="3", - ) + ), + when="signed-out", ), - clerk.signed_in( + clerk.show( rx.text("Sign out to see links to default sign-in and sign-up pages."), clerk.sign_out_button(rx.button("Sign out", width="100%")), + when="signed-in", ), ) return demo_card( @@ -531,7 +537,7 @@ def user_info_demo() -> rx.Component: Test credentials will not have a name or image by default. """) ), - clerk.signed_in( + clerk.show( rx.hstack( rx.card( rx.data_list.root( @@ -548,9 +554,10 @@ def user_info_demo() -> rx.Component: width="100%", justify="center", spacing="5", - ) + ), + when="signed-in", ), - clerk.signed_out(rx.text("Sign in to see user information.")), + clerk.show(rx.text("Sign in to see user information."), when="signed-out"), ) return demo_card( @@ -629,10 +636,11 @@ def demo_header() -> rx.Component: data_list_item("password", rx.code("test-clerk-password")), ), rx.hstack( - clerk.signed_in( - clerk.sign_out_button(rx.button("Sign out", data_testid="sign_out")) + clerk.show( + clerk.sign_out_button(rx.button("Sign out", data_testid="sign_out")), + when="signed-in", ), - clerk.signed_out( + clerk.show( rx.hstack( clerk.sign_in_button( rx.button("Sign in", data_testid="sign_in") @@ -641,6 +649,7 @@ def demo_header() -> rx.Component: rx.button("Sign up", data_testid="sign_up") ), ), + when="signed-out", ), ), ), diff --git a/custom_components/reflex_clerk_api/__init__.py b/custom_components/reflex_clerk_api/__init__.py index 02dec52..f3995d0 100644 --- a/custom_components/reflex_clerk_api/__init__.py +++ b/custom_components/reflex_clerk_api/__init__.py @@ -13,10 +13,7 @@ from .control_components import ( clerk_loaded, clerk_loading, - protect, redirect_to_user_profile, - signed_in, - signed_out, show, ) from .billing_components import pricing_table @@ -49,7 +46,6 @@ "organization_list", "organization_profile", "organization_switcher", - "protect", "pricing_table", "redirect_to_user_profile", "register_on_auth_change_handler", @@ -59,8 +55,6 @@ "sign_up", "sign_up_button", "show", - "signed_in", - "signed_out", "update_user_phone_number", "user_button", "user_profile", diff --git a/custom_components/reflex_clerk_api/control_components.py b/custom_components/reflex_clerk_api/control_components.py index 7801340..32dbc6b 100644 --- a/custom_components/reflex_clerk_api/control_components.py +++ b/custom_components/reflex_clerk_api/control_components.py @@ -2,8 +2,6 @@ from reflex_clerk_api.base import ClerkBase -Javascript = str -JSX = str SignInInitialValues = dict[str, str] SignUpInitialValues = dict[str, str] @@ -20,19 +18,6 @@ class ClerkLoading(ClerkBase): tag = "ClerkLoading" -class Protect(ClerkBase): - tag = "Protect" - - condition: Javascript | None = None - "Optional conditional logic that renders the children if it returns true" - fallback: JSX | None = None - "An optional snippet of JSX to show when a user doesn't have the role or permission to access the protected content." - permission: str | None = None - "Optional string corresponding to a Role's Permission in the format org::" - role: str | None = None - "Optional string corresponding to an Organization's Role in the format org:" - - class Show(ClerkBase): tag = "Show" @@ -89,26 +74,11 @@ class RedirectToCreateOrganization(ClerkBase): tag = "RedirectToCreateOrganization" -class SignedIn(ClerkBase): - """Only renders children when the user is signed in.""" - - tag = "SignedIn" - - -class SignedOut(ClerkBase): - """Only renders children when the user is signed out.""" - - tag = "SignedOut" - - clerk_loaded = ClerkLoaded.create clerk_loading = ClerkLoading.create -protect = Protect.create show = Show.create redirect_to_sign_in = RedirectToSignIn.create redirect_to_sign_up = RedirectToSignUp.create redirect_to_user_profile = RedirectToUserProfile.create redirect_to_organization_profile = RedirectToOrganizationProfile.create redirect_to_create_organization = RedirectToCreateOrganization.create -signed_in = SignedIn.create -signed_out = SignedOut.create diff --git a/docs/about.md b/docs/about.md index 2a55880..b227c65 100644 --- a/docs/about.md +++ b/docs/about.md @@ -4,14 +4,14 @@ Welcome to the Reflex Clerk API documentation! This package provides integration ## Overview -Primarily, this package wraps the [@clerk/clerk-react](https://www.npmjs.com/package/@clerk/clerk-react) library, using the Clerk maintained [clerk-backend-api](https://pypi.org/project/clerk-backend-api/") python package to synchronize the reflex FastAPI backend with the Clerk frontend components. +Primarily, this package wraps the [@clerk/react](https://www.npmjs.com/package/@clerk/react) library, using the Clerk maintained [clerk-backend-api](https://pypi.org/project/clerk-backend-api/") python package to synchronize the reflex FastAPI backend with the Clerk frontend components. ### Wrapped Components An overview of some of the clerk-react components that are wrapped here: - **ClerkProvider**: A component that wraps your app/page to handle Clerk authentication. -- **Control Components**: Components such as `clerk_loaded`, `protect`, and `signed_in`, etc. +- **Control Components**: Components such as `clerk_loaded`, `clerk_loading`, and `show`. - **Authentication Components**: Components for `sign_in` and `sign_up` that redirect the user to Clerk's authentication pages. - **Wrapper Components**: Button wrappers for `sign_in_button`, `sign_out_button`, and `user_button`, that you can wrap regular reflex components with. diff --git a/docs/features.md b/docs/features.md index ffe564a..5ed5685 100644 --- a/docs/features.md +++ b/docs/features.md @@ -79,7 +79,7 @@ These determine what content is displayed based on the user's authentication sta - **ClerkLoading**: Displays content while Clerk is loading. -- **Protect**: Protects specific content to ensure only authenticated users can access them. +- **Show**: Conditionally renders content using `when="signed-in"`, `when="signed-out"`, or entitlement checks like `when={"permission": "org:x:y"}`. - **RedirectToSignIn**: Redirects users to the sign-in page if they are not authenticated. @@ -91,7 +91,6 @@ These determine what content is displayed based on the user's authentication sta - **RedirectToCreateOrganization**: Redirects users to create an organization. -- **SignedIn** and **SignedOut**: Conditional rendering based on user authentication state. ### Unstyled Components diff --git a/docs/getting_started.md b/docs/getting_started.md index e5a7ad1..7bfd404 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -64,11 +64,13 @@ def index() -> rx.Component: rx.spinner(), ), clerk.clerk_loaded( - clerk.signed_in( - clerk.sign_out_button(rx.button("Sign out")) + clerk.show( + clerk.sign_out_button(rx.button("Sign out")), + when="signed-in", ), - clerk.signed_out( - clerk.sign_in_button(rx.button("Sign in")) + clerk.show( + clerk.sign_in_button(rx.button("Sign in")), + when="signed-out", ), ), publishable_key=os.environ["CLERK_PUBLISHABLE_KEY"], diff --git a/pyproject.toml b/pyproject.toml index 0c77c2e..b0e8b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ path = "custom_components/reflex_clerk_api/__init__.py" [project] name = "reflex-clerk-api" -description = "Reflex custom component wrapping @clerk/clerk-react and integrating the clerk-backend-api" +description = "Reflex custom component wrapping @clerk/react and integrating the clerk-backend-api" readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.10" diff --git a/tests/test_public_api_exports.py b/tests/test_public_api_exports.py new file mode 100644 index 0000000..79b4c7b --- /dev/null +++ b/tests/test_public_api_exports.py @@ -0,0 +1,11 @@ +import reflex_clerk_api as clerk + + +def test_show_is_exported(): + assert hasattr(clerk, "show") + + +def test_deprecated_control_helpers_are_not_exported(): + assert not hasattr(clerk, "protect") + assert not hasattr(clerk, "signed_in") + assert not hasattr(clerk, "signed_out") From dc0a9943fcf3e08896f80f82a76a9899bafe5dd8 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Wed, 29 Apr 2026 14:54:20 +0100 Subject: [PATCH 19/22] Fix Reflex background lock usage for Clerk auth wait and user load - wait_for_auth_check: snapshot auth_checked inside async with self; sleep outside lock; use monotonic deadline - ClerkUser.load_user: background read/write phases; Clerk API I/O outside locks; add ClerkState.get_clerk_client() for shared client without holding clerk_state --- .../reflex_clerk_api/clerk_provider.py | 118 +++++++++++------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index d73f63c..3eb7c85 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -111,12 +111,17 @@ def set_jwt_validate_leeway_seconds(cls, seconds: int) -> None: ) cls._jwt_validate_leeway_seconds = seconds + @classmethod + def get_clerk_client(cls) -> clerk_backend_api.Clerk: + """Shared Clerk backend client (class-backed); safe to use outside state locks.""" + if cls._client is None: + cls._set_client() + assert cls._client is not None + return cls._client + @property def client(self) -> clerk_backend_api.Clerk: - if self._client is None: - self._set_client() - assert self._client is not None - return self._client + return type(self).get_clerk_client() @rx.event(background=True) async def set_clerk_session(self, token: str) -> EventType: @@ -185,9 +190,11 @@ async def wait_for_auth_check(self, uid: uuid.UUID | str) -> EventType: logging.warning("Waited for auth, but no on_load events registered.") on_loads = [] - start_time = time.time() - while time.time() - start_time < self._auth_wait_timeout_seconds: - if self.auth_checked: + deadline = time.monotonic() + type(self)._auth_wait_timeout_seconds + while time.monotonic() < deadline: + async with self: + auth_checked = self.auth_checked + if auth_checked: logging.debug("Auth check complete") return on_loads logging.debug("...waiting for auth...") @@ -290,52 +297,67 @@ class ClerkUser(rx.State): # Set to True when the state is registered on the ClerkState to avoid registering it multiple times. _is_registered: ClassVar[bool] = False - @rx.event + @rx.event(background=True) async def load_user(self) -> None: - try: - user: clerk_backend_api.models.User = await get_user(self) - except MissingUserError: + async with self: + clerk_state = await self.get_state(ClerkState) + + async with clerk_state: + user_id = clerk_state.user_id + + client = ClerkState.get_clerk_client() + + if user_id is None: + logging.debug("Clearing user state") + async with self: + self.reset() + return + + user = await client.users.get_async(user_id=user_id) + if user is None: logging.debug("Clearing user state") - self.reset() + async with self: + self.reset() return logging.debug("Updating user state") - self.first_name = ( - user.first_name - if user.first_name and user.first_name != clerk_backend_api.UNSET - else "" - ) - self.last_name = ( - user.last_name - if user.last_name and user.last_name != clerk_backend_api.UNSET - else "" - ) - self.username = ( - user.username - if user.username and user.username != clerk_backend_api.UNSET - else "" - ) - self.email_address = ( - user.email_addresses[0].email_address if user.email_addresses else "" - ) - # Load primary phone number and its ID, falling back to the first if needed - if user.phone_numbers: - primary_phone = None - primary_id = getattr(user, "primary_phone_number_id", None) - if primary_id is not None: - for pn in user.phone_numbers: - if getattr(pn, "id", None) == primary_id: - primary_phone = pn - break - if primary_phone is None: - primary_phone = user.phone_numbers[0] - self.phone_number = getattr(primary_phone, "phone_number", "") or "" - self.phone_number_id = getattr(primary_phone, "id", "") or "" - else: - self.phone_number = "" - self.phone_number_id = "" - self.has_image = True if user.has_image is True else False - self.image_url = user.image_url or "" + async with self: + self.first_name = ( + user.first_name + if user.first_name and user.first_name != clerk_backend_api.UNSET + else "" + ) + self.last_name = ( + user.last_name + if user.last_name and user.last_name != clerk_backend_api.UNSET + else "" + ) + self.username = ( + user.username + if user.username and user.username != clerk_backend_api.UNSET + else "" + ) + self.email_address = ( + user.email_addresses[0].email_address if user.email_addresses else "" + ) + # Load primary phone number and its ID, falling back to the first if needed + if user.phone_numbers: + primary_phone = None + primary_id = getattr(user, "primary_phone_number_id", None) + if primary_id is not None: + for pn in user.phone_numbers: + if getattr(pn, "id", None) == primary_id: + primary_phone = pn + break + if primary_phone is None: + primary_phone = user.phone_numbers[0] + self.phone_number = getattr(primary_phone, "phone_number", "") or "" + self.phone_number_id = getattr(primary_phone, "id", "") or "" + else: + self.phone_number = "" + self.phone_number_id = "" + self.has_image = True if user.has_image is True else False + self.image_url = user.image_url or "" @rx.event async def update_phone_number( From 7210930ef69920b913e062304bfca7a2a1f99486 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Wed, 29 Apr 2026 15:02:53 +0100 Subject: [PATCH 20/22] Fix wait_for_auth_check deadline with StateProxy (use ClerkState class var) --- custom_components/reflex_clerk_api/clerk_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 3eb7c85..50e0639 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -190,7 +190,7 @@ async def wait_for_auth_check(self, uid: uuid.UUID | str) -> EventType: logging.warning("Waited for auth, but no on_load events registered.") on_loads = [] - deadline = time.monotonic() + type(self)._auth_wait_timeout_seconds + deadline = time.monotonic() + ClerkState._auth_wait_timeout_seconds while time.monotonic() < deadline: async with self: auth_checked = self.auth_checked From 41e2e26eeea7bc1df139433415b572f4915d0e20 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Wed, 29 Apr 2026 16:29:31 +0100 Subject: [PATCH 21/22] Handle expired JWT auth: skip sending stale tokens; mark auth checked on validation failure --- .../reflex_clerk_api/clerk_provider.py | 22 ++++++++++++++++++- tests/test_clerk_provider_unit.py | 5 ++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 50e0639..0e0e52c 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -154,7 +154,12 @@ async def set_clerk_session(self, token: str) -> EventType: jose_errors.MissingClaimError, ) as e: logging.warning(f"JWT token validation failed: {type(e).__name__}: {e}") - return ClerkState.clear_clerk_session + async with self: + self.is_signed_in = False + self.claims = None + self.user_id = None + self.auth_checked = True + return list(self._dependent_handlers.values()) async with self: self.is_signed_in = True @@ -432,6 +437,15 @@ def add_custom_code(self) -> list[str]: const [ addEvents ] = useContext(EventLoopContext) const lastSentRef = useRef({{ stateKey: null, addEvents: null }}) + const isJwtExpired = (token) => {{ + try {{ + const payload = JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))) + return typeof payload.exp === "number" && payload.exp <= Math.floor(Date.now() / 1000) + }} catch {{ + return false + }} + }} + useEffect(() => {{ // Wait for all dependencies to be ready. if (!isLoaded || !addEvents) return @@ -453,6 +467,12 @@ def add_custom_code(self) -> list[str]: .catch(() => getToken()) .then(token => {{ if (token) {{ + if (isJwtExpired(token)) {{ + // Avoid sending already-expired JWTs to the backend, which would otherwise leave + // auth waiters racing a validation failure. + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + return + }} addEvents([ReflexEvent("{state}.set_clerk_session", {{token}})]) }} else {{ // Token unavailable despite isSignedIn - clear to avoid stuck auth state. diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py index 1f21156..7e3e441 100644 --- a/tests/test_clerk_provider_unit.py +++ b/tests/test_clerk_provider_unit.py @@ -38,7 +38,9 @@ def validate(self, leeway=None): result = asyncio.run(ClerkState.set_clerk_session.fn(state, token="fake")) assert validate_calls["leeway"] == 60 - assert result == ClerkState.clear_clerk_session + assert result == [] + assert state.auth_checked is True + assert state.is_signed_in is False def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcache(): @@ -48,6 +50,7 @@ def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcach js = ClerkSessionSynchronizer.create().add_custom_code()[0] assert "[isLoaded, isSignedIn, addEvents, getToken]" in js assert "skipCache: true" in js + assert "isJwtExpired(token)" in js def test_clerk_provider_adds_clerk_ui_import_by_default(): From 2f685a3a8671699ef45b1d6a97b9d35e2188c3fa Mon Sep 17 00:00:00 2001 From: Uzair Patel Date: Thu, 7 May 2026 16:35:14 +0100 Subject: [PATCH 22/22] Pin Clerk frontend version set --- README.md | 2 + .../reflex_clerk_api/__init__.py | 44 ++++- custom_components/reflex_clerk_api/base.py | 158 +++++++++++++++++- .../reflex_clerk_api/clerk_provider.py | 16 +- docs/getting_started.md | 22 +++ tests/test_clerk_provider_unit.py | 142 +++++++++++++++- 6 files changed, 369 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9900a00..340d872 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ def index() -> rx.Component: ) ``` +The package pins a compatible Clerk frontend set by default (`@clerk/react@6.6.0`, `@clerk/ui@1.9.0`, ClerkJS `6.10.0`). Apps that need another compatible set can call `clerk.configure_clerk_frontend_versions(...)` before creating Clerk components. + ## Contributing Feel free to open issues or make PRs. diff --git a/custom_components/reflex_clerk_api/__init__.py b/custom_components/reflex_clerk_api/__init__.py index f3995d0..e10e0fa 100644 --- a/custom_components/reflex_clerk_api/__init__.py +++ b/custom_components/reflex_clerk_api/__init__.py @@ -1,6 +1,21 @@ -__version__ = "1.2.4" +__version__ = "1.3.0" from .authentication_components import sign_in, sign_up +from .base import ( + CLERK_JS_VERSION, + CLERK_REACT_LIBRARY, + CLERK_REACT_VERSION, + CLERK_UI_LIBRARY, + CLERK_UI_VERSION, + DEFAULT_CLERK_FRONTEND_VERSIONS, + ClerkFrontendVersions, + configure_clerk_frontend_versions, + get_clerk_frontend_versions, + get_clerk_react_library, + get_clerk_ui_library, + reset_clerk_frontend_versions, +) +from .billing_components import pricing_table from .clerk_provider import ( ClerkState, ClerkUser, @@ -16,7 +31,12 @@ redirect_to_user_profile, show, ) -from .billing_components import pricing_table +from .organization_components import ( + create_organization, + organization_list, + organization_profile, + organization_switcher, +) from .pages import add_sign_in_page, add_sign_up_page from .unstyled_components import ( SignInButton, @@ -25,14 +45,15 @@ sign_up_button, ) from .user_components import user_button, user_profile -from .organization_components import ( - create_organization, - organization_profile, - organization_switcher, - organization_list, -) __all__ = [ + "CLERK_JS_VERSION", + "CLERK_REACT_LIBRARY", + "CLERK_REACT_VERSION", + "CLERK_UI_LIBRARY", + "CLERK_UI_VERSION", + "DEFAULT_CLERK_FRONTEND_VERSIONS", + "ClerkFrontendVersions", "ClerkState", "ClerkUser", "SignInButton", @@ -41,7 +62,11 @@ "clerk_loaded", "clerk_loading", "clerk_provider", + "configure_clerk_frontend_versions", "create_organization", + "get_clerk_frontend_versions", + "get_clerk_react_library", + "get_clerk_ui_library", "on_load", "organization_list", "organization_profile", @@ -49,12 +74,13 @@ "pricing_table", "redirect_to_user_profile", "register_on_auth_change_handler", + "reset_clerk_frontend_versions", + "show", "sign_in", "sign_in_button", "sign_out_button", "sign_up", "sign_up_button", - "show", "update_user_phone_number", "user_button", "user_profile", diff --git a/custom_components/reflex_clerk_api/base.py b/custom_components/reflex_clerk_api/base.py index 9ea088d..263c5bb 100644 --- a/custom_components/reflex_clerk_api/base.py +++ b/custom_components/reflex_clerk_api/base.py @@ -1,7 +1,163 @@ +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass, replace + import reflex as rx +def _versioned_package(package: str, version: str) -> str: + return f"{package}@{version}" + + +@dataclass(frozen=True) +class ClerkFrontendVersions: + """Frontend package versions that must stay compatible with each other.""" + + react_version: str + ui_version: str + clerk_js_version: str + shared_version: str | None = None + localizations_version: str | None = None + tanstack_query_core_version: str | None = None + + @property + def react_library(self) -> str: + """The versioned @clerk/react package import used by Reflex.""" + return _versioned_package("@clerk/react", self.react_version) + + @property + def ui_library(self) -> str: + """The versioned @clerk/ui package import used by Reflex.""" + return _versioned_package("@clerk/ui", self.ui_version) + + @property + def dependency_libraries(self) -> tuple[str, ...]: + """Additional frontend dependencies to install alongside Clerk.""" + dependencies = ( + ("@clerk/shared", self.shared_version), + ("@clerk/localizations", self.localizations_version), + ("@tanstack/query-core", self.tanstack_query_core_version), + ) + return tuple( + _versioned_package(package, version) + for package, version in dependencies + if version is not None + ) + + +DEFAULT_CLERK_FRONTEND_VERSIONS = ClerkFrontendVersions( + react_version="6.6.0", + ui_version="1.9.0", + clerk_js_version="6.10.0", + shared_version="4.10.1", + localizations_version="4.6.1", + tanstack_query_core_version="5.100.9", +) + +CLERK_REACT_VERSION = DEFAULT_CLERK_FRONTEND_VERSIONS.react_version +CLERK_UI_VERSION = DEFAULT_CLERK_FRONTEND_VERSIONS.ui_version +CLERK_JS_VERSION = DEFAULT_CLERK_FRONTEND_VERSIONS.clerk_js_version +CLERK_REACT_LIBRARY = DEFAULT_CLERK_FRONTEND_VERSIONS.react_library +CLERK_UI_LIBRARY = DEFAULT_CLERK_FRONTEND_VERSIONS.ui_library + +_clerk_frontend_versions = DEFAULT_CLERK_FRONTEND_VERSIONS + + class ClerkBase(rx.Component): # The React library to wrap. # `Show` is exported from `@clerk/react` (v6+), not `@clerk/clerk-react`. - library = "@clerk/react@^6.2.0" + library = CLERK_REACT_LIBRARY + lib_dependencies = list(DEFAULT_CLERK_FRONTEND_VERSIONS.dependency_libraries) + + def __init_subclass__(cls, **kwargs): + """Keep future subclasses aligned with the active frontend version set.""" + super().__init_subclass__(**kwargs) + _sync_clerk_component_class(cls) + + +def get_clerk_frontend_versions() -> ClerkFrontendVersions: + """Return the active Clerk frontend version set.""" + return _clerk_frontend_versions + + +def get_clerk_react_library() -> str: + """Return the active versioned @clerk/react package name.""" + return _clerk_frontend_versions.react_library + + +def get_clerk_ui_library() -> str: + """Return the active versioned @clerk/ui package name.""" + return _clerk_frontend_versions.ui_library + + +def configure_clerk_frontend_versions( + versions: ClerkFrontendVersions | None = None, + *, + react_version: str | None = None, + ui_version: str | None = None, + clerk_js_version: str | None = None, + shared_version: str | None = None, + localizations_version: str | None = None, + tanstack_query_core_version: str | None = None, +) -> ClerkFrontendVersions: + """Configure the Clerk frontend packages emitted by Reflex. + + Call this before creating pages/components if an app needs to override the + package defaults. Already-imported Clerk component classes are updated too, + including Reflex's stored field defaults. + """ + global _clerk_frontend_versions + + next_versions = versions or _clerk_frontend_versions + replacements = { + "react_version": react_version, + "ui_version": ui_version, + "clerk_js_version": clerk_js_version, + "shared_version": shared_version, + "localizations_version": localizations_version, + "tanstack_query_core_version": tanstack_query_core_version, + } + next_versions = replace( + next_versions, + **{key: value for key, value in replacements.items() if value is not None}, + ) + + _clerk_frontend_versions = next_versions + for component_cls in _iter_clerk_component_classes(): + _sync_clerk_component_class(component_cls) + return next_versions + + +def reset_clerk_frontend_versions() -> ClerkFrontendVersions: + """Reset Clerk frontend packages to the package defaults.""" + return configure_clerk_frontend_versions(DEFAULT_CLERK_FRONTEND_VERSIONS) + + +def _sync_clerk_component_class(component_cls: type[rx.Component]) -> None: + """Sync class attributes and Reflex field defaults for Clerk components.""" + versions = get_clerk_frontend_versions() + component_cls.library = versions.react_library + component_cls.lib_dependencies = list(versions.dependency_libraries) + + fields = component_cls.get_fields() + if "library" in fields: + fields["library"].default = versions.react_library + if "lib_dependencies" in fields: + fields["lib_dependencies"].default = list(versions.dependency_libraries) + if "clerk_js_version" in fields: + setattr(component_cls, "clerk_js_version", versions.clerk_js_version) + fields["clerk_js_version"].default = versions.clerk_js_version + + +def _iter_clerk_component_classes() -> Iterator[type[rx.Component]]: + yield ClerkBase + yield from _iter_clerk_component_subclasses(ClerkBase) + + +def _iter_clerk_component_subclasses( + component_cls: type[rx.Component], +) -> Iterator[type[rx.Component]]: + for subclass in component_cls.__subclasses__(): + yield subclass + yield from _iter_clerk_component_subclasses(subclass) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 0e0e52c..166efc9 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -14,7 +14,12 @@ from reflex.utils.exceptions import ImmutableStateError from reflex.vars.base import Var -from reflex_clerk_api.base import ClerkBase +from reflex_clerk_api.base import ( + ClerkBase, + get_clerk_frontend_versions, + get_clerk_react_library, + get_clerk_ui_library, +) from .models import Appearance @@ -420,7 +425,7 @@ def add_imports( self, ) -> rx.ImportDict: addl_imports: rx.ImportDict = { - "@clerk/react": ["useAuth"], + get_clerk_react_library(): ["useAuth"], "react": ["useContext", "useEffect", "useRef"], "$/utils/context": ["EventLoopContext"], "$/utils/state": ["ReflexEvent"], @@ -552,7 +557,7 @@ class ClerkProvider(ClerkBase): clerk_js_variant: str | None = None """If your web application only uses control components, set this to 'headless'.""" - clerk_js_version: str = "" + clerk_js_version: str = get_clerk_frontend_versions().clerk_js_version """Define the npm version for @clerk/clerk-js.""" # domain: str | JSCallable[[str], bool] = "" @@ -634,13 +639,16 @@ class ClerkProvider(ClerkBase): def add_imports(self) -> rx.ImportDict: # Import ui package to pin Clerk component versions when using structural CSS. - return {"@clerk/ui": ["ui"]} + return {get_clerk_ui_library(): ["ui"]} @classmethod def create(cls, *children, **props) -> Self: # Default to ui={ui} unless caller explicitly supplies a different ui config. if "ui" not in props: props["ui"] = Var(_js_expr="ui", _var_type=Any) + props.setdefault( + "clerk_js_version", get_clerk_frontend_versions().clerk_js_version + ) return cast(Self, super().create(*children, **props)) def add_custom_code(self) -> list[str]: diff --git a/docs/getting_started.md b/docs/getting_started.md index 7bfd404..e1ec3aa 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -101,6 +101,28 @@ clerk.wrap_app(app, publishable_key=...) Taking the same arguments as `clerk.clerk_provider`. +### Frontend Package Versions + +`reflex-clerk-api` pins a compatible Clerk frontend set by default: + +- `@clerk/react@6.6.0` +- `@clerk/ui@1.9.0` +- ClerkJS `6.10.0` + +If your app needs a different compatible set, configure it before creating Clerk components or importing page modules that create them: + +```python +import reflex_clerk_api as clerk + +clerk.configure_clerk_frontend_versions( + react_version="6.6.0", + ui_version="1.9.0", + clerk_js_version="6.10.0", +) +``` + +The configuration updates both `ClerkBase.library` and Reflex's stored component field defaults, so `clerk_provider(...)`, `wrap_app(...)`, and direct `ClerkProvider.create(...)` calls use the same version set. + ### Environment Variables A good way to provide the keys is via environment variables (to avoid accidentally sharing them). You can do this by creating a `.env` file in the root of your project with: diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py index 7e3e441..6e4ecc4 100644 --- a/tests/test_clerk_provider_unit.py +++ b/tests/test_clerk_provider_unit.py @@ -1,6 +1,8 @@ import asyncio import authlib.jose.errors as jose_errors +import reflex as rx +from reflex.utils.imports import ImportVar def test_set_clerk_session_expired_token_clears(monkeypatch): @@ -53,11 +55,42 @@ def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcach assert "isJwtExpired(token)" in js +def test_clerk_session_synchronizer_imports_pinned_clerk_react(): + from reflex_clerk_api.base import CLERK_REACT_LIBRARY + from reflex_clerk_api.clerk_provider import ClerkSessionSynchronizer + + imports = ClerkSessionSynchronizer.create().add_imports() + assert imports.get(CLERK_REACT_LIBRARY) == ["useAuth"] + assert "@clerk/react" not in imports + + +def test_clerk_base_components_import_pinned_clerk_react(): + from reflex_clerk_api.base import ( + CLERK_REACT_LIBRARY, + CLERK_REACT_VERSION, + DEFAULT_CLERK_FRONTEND_VERSIONS, + ClerkBase, + ) + from reflex_clerk_api.user_components import UserButton + + component = UserButton.create() + imports = component._get_imports() + assert ClerkBase.library == f"@clerk/react@{CLERK_REACT_VERSION}" + assert component.library == CLERK_REACT_LIBRARY + assert UserButton.get_fields()["library"].default == CLERK_REACT_LIBRARY + assert CLERK_REACT_LIBRARY in imports + for dependency in DEFAULT_CLERK_FRONTEND_VERSIONS.dependency_libraries: + assert dependency in imports + assert all(import_var.render is False for import_var in imports[dependency]) + + def test_clerk_provider_adds_clerk_ui_import_by_default(): + from reflex_clerk_api.base import CLERK_UI_LIBRARY from reflex_clerk_api.clerk_provider import ClerkProvider imports = ClerkProvider.create().add_imports() - assert imports.get("@clerk/ui") == ["ui"] + assert imports.get(CLERK_UI_LIBRARY) == ["ui"] + assert "@clerk/ui" not in imports def test_clerk_provider_defaults_ui_prop_to_imported_ui_symbol(): @@ -72,3 +105,110 @@ def test_clerk_provider_allows_ui_override(): props = ClerkProvider.create(ui="custom-ui").render()["props"] assert 'ui:"custom-ui"' in props + + +def test_clerk_provider_defaults_clerk_js_version(): + from reflex_clerk_api.base import CLERK_JS_VERSION + from reflex_clerk_api.clerk_provider import ClerkProvider, clerk_provider + + assert f'clerkJsVersion:"{CLERK_JS_VERSION}"' in ClerkProvider.create().render()[ + "props" + ] + assert f'clerkJsVersion:"{CLERK_JS_VERSION}"' in clerk_provider( + publishable_key="pk_test" + ).render()["props"] + + +def test_clerk_provider_allows_clerk_js_version_override(): + from reflex_clerk_api.clerk_provider import ClerkProvider, clerk_provider + + assert 'clerkJsVersion:"6.99.0"' in ClerkProvider.create( + clerk_js_version="6.99.0" + ).render()["props"] + assert 'clerkJsVersion:"6.99.0"' in clerk_provider( + publishable_key="pk_test", + clerk_js_version="6.99.0", + ).render()["props"] + + +def test_wrap_app_defaults_clerk_js_version(): + from reflex_clerk_api.base import CLERK_JS_VERSION + from reflex_clerk_api.clerk_provider import wrap_app + + app = rx.App() + wrap_app(app, publishable_key="pk_test") + + component = app.app_wraps[(1, "ClerkProvider")](None) + assert f'clerkJsVersion:"{CLERK_JS_VERSION}"' in component.render()["props"] + + +def test_wrap_app_allows_clerk_js_version_override(): + from reflex_clerk_api.clerk_provider import wrap_app + + app = rx.App() + wrap_app(app, publishable_key="pk_test", clerk_js_version="6.99.0") + + component = app.app_wraps[(1, "ClerkProvider")](None) + assert 'clerkJsVersion:"6.99.0"' in component.render()["props"] + + +def test_configure_clerk_frontend_versions_updates_field_defaults(): + from reflex_clerk_api.base import ( + DEFAULT_CLERK_FRONTEND_VERSIONS, + configure_clerk_frontend_versions, + reset_clerk_frontend_versions, + ) + from reflex_clerk_api.clerk_provider import ClerkProvider + from reflex_clerk_api.user_components import UserButton + + try: + versions = configure_clerk_frontend_versions( + react_version="6.7.0", + ui_version="1.10.0", + clerk_js_version="6.11.0", + ) + + assert versions.react_library == "@clerk/react@6.7.0" + assert UserButton.library == versions.react_library + assert UserButton.lib_dependencies == list(versions.dependency_libraries) + assert UserButton.get_fields()["library"].default == versions.react_library + assert UserButton.get_fields()["lib_dependencies"].default == list( + versions.dependency_libraries + ) + assert UserButton.create().library == versions.react_library + assert versions.react_library in UserButton.create()._get_imports() + assert ClerkProvider.create().add_imports().get(versions.ui_library) == ["ui"] + assert ( + ClerkProvider.get_fields()["clerk_js_version"].default + == versions.clerk_js_version + ) + assert 'clerkJsVersion:"6.11.0"' in ClerkProvider.create().render()["props"] + finally: + configure_clerk_frontend_versions(DEFAULT_CLERK_FRONTEND_VERSIONS) + reset_clerk_frontend_versions() + + +def test_custom_component_reads_clerk_base_library_at_call_time(): + from reflex_clerk_api.base import ( + DEFAULT_CLERK_FRONTEND_VERSIONS, + ClerkBase, + configure_clerk_frontend_versions, + ) + + class CustomUserButton(rx.Component): + library = None + tag = "CustomUserButton" + + def _get_imports(self): + return {ClerkBase.library: [ImportVar(tag="UserButton")]} + + try: + configure_clerk_frontend_versions( + react_version="6.8.0", + ui_version="1.11.0", + clerk_js_version="6.12.0", + ) + + assert "@clerk/react@6.8.0" in CustomUserButton.create()._get_imports() + finally: + configure_clerk_frontend_versions(DEFAULT_CLERK_FRONTEND_VERSIONS)