diff --git a/README.md b/README.md index 8ce53c7..340d872 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", ), ), ), @@ -47,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. @@ -69,4 +73,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 e480af7..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, @@ -13,10 +28,14 @@ from .control_components import ( clerk_loaded, clerk_loading, - protect, redirect_to_user_profile, - signed_in, - signed_out, + show, +) +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 ( @@ -28,6 +47,13 @@ from .user_components import user_button, user_profile __all__ = [ + "CLERK_JS_VERSION", + "CLERK_REACT_LIBRARY", + "CLERK_REACT_VERSION", + "CLERK_UI_LIBRARY", + "CLERK_UI_VERSION", + "DEFAULT_CLERK_FRONTEND_VERSIONS", + "ClerkFrontendVersions", "ClerkState", "ClerkUser", "SignInButton", @@ -36,17 +62,25 @@ "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", - "protect", + "organization_list", + "organization_profile", + "organization_switcher", + "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", - "signed_in", - "signed_out", "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 00c9305..263c5bb 100644 --- a/custom_components/reflex_clerk_api/base.py +++ b/custom_components/reflex_clerk_api/base.py @@ -1,6 +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. - library = "@clerk/clerk-react" + # `Show` is exported from `@clerk/react` (v6+), not `@clerk/clerk-react`. + 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/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/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index eccc235..166efc9 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -12,8 +12,14 @@ 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 +from reflex_clerk_api.base import ( + ClerkBase, + get_clerk_frontend_versions, + get_clerk_react_library, + get_clerk_ui_library, +) from .models import Appearance @@ -59,6 +65,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: @@ -85,12 +93,40 @@ 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. + + Args: + seconds: Non-negative integer, max 3600 (1 hour). + + Raises: + ValueError: If seconds is negative or exceeds 3600. + """ + 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}" + ) + if seconds > 3600: + raise ValueError( + f"jwt_validate_leeway_seconds exceeds maximum of 3600 (1 hour), got {seconds}" + ) + 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: @@ -116,10 +152,19 @@ 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}") - return ClerkState.clear_clerk_session + 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}") + 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 @@ -155,9 +200,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() + ClerkState._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...") @@ -260,52 +307,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") - self.reset() + 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") + 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( @@ -363,8 +425,8 @@ def add_imports( self, ) -> rx.ImportDict: addl_imports: rx.ImportDict = { - "@clerk/clerk-react": ["useAuth"], - "react": ["useContext", "useEffect"], + get_clerk_react_library(): ["useAuth"], + "react": ["useContext", "useEffect", "useRef"], "$/utils/context": ["EventLoopContext"], "$/utils/state": ["ReflexEvent"], } @@ -375,28 +437,66 @@ def add_custom_code(self) -> list[str]: return [ """ -function ClerkSessionSynchronizer({ children }) { - const { getToken, isLoaded, isSignedIn } = useAuth() - const [ addEvents, connectErrors ] = useContext(EventLoopContext) - - useEffect(() => { - if (isLoaded && !!addEvents) { - if (isSignedIn) { - getToken().then(token => { - addEvents([ReflexEvent("%s.set_clerk_session", {token})]) - }) - } else { - addEvents([ReflexEvent("%s.clear_clerk_session")]) - } - } - }, [isSignedIn]) +function ClerkSessionSynchronizer({{ children }}) {{ + const {{ getToken, isLoaded, isSignedIn }} = useAuth() + 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 + + // 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) {{ + 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. + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }} + }}).catch(() => {{ + // Token retrieval failed - clear to avoid stuck auth state. + 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) +}} +""".format(state=clerk_state_name) ] @@ -432,6 +532,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.""" @@ -454,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] = "" @@ -534,8 +637,18 @@ 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 {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/custom_components/reflex_clerk_api/control_components.py b/custom_components/reflex_clerk_api/control_components.py index a775cdd..32dbc6b 100644 --- a/custom_components/reflex_clerk_api/control_components.py +++ b/custom_components/reflex_clerk_api/control_components.py @@ -1,7 +1,7 @@ +import reflex as rx + from reflex_clerk_api.base import ClerkBase -Javascript = str -JSX = str SignInInitialValues = dict[str, str] SignUpInitialValues = dict[str, str] @@ -18,17 +18,20 @@ class ClerkLoading(ClerkBase): tag = "ClerkLoading" -class Protect(ClerkBase): - tag = "Protect" +class Show(ClerkBase): + tag = "Show" - 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:" + 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): @@ -71,25 +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/custom_components/reflex_clerk_api/organization_components.py b/custom_components/reflex_clerk_api/organization_components.py index 3883d0e..bb1ced1 100644 --- a/custom_components/reflex_clerk_api/organization_components.py +++ b/custom_components/reflex_clerk_api/organization_components.py @@ -1,6 +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): @@ -9,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): @@ -35,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): @@ -63,35 +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_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 + 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): @@ -100,31 +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_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 + 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 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..e1ec3aa 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"], @@ -99,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/pyproject.toml b/pyproject.toml index 85bc7d8..b0e8b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,17 +16,20 @@ 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 = "Apache-2.0" -requires-python = ">=3.11" -authors = [{ name = "Tim Child", email = "timjchild@gmail.com" }] +license = { text = "Apache-2.0" } +requires-python = ">=3.10" +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"] dependencies = [ - "authlib>=1.5.1,<2.0.0", - "clerk-backend-api>=2.0.0,<3.0.0", + "authlib>=1.5.1", + "clerk-backend-api>=5.0.0", "reflex>=0.8.0", ] @@ -47,6 +50,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 new file mode 100644 index 0000000..6e4ecc4 --- /dev/null +++ b/tests/test_clerk_provider_unit.py @@ -0,0 +1,214 @@ +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): + """Expired tokens should not crash the handler; they should clear session.""" + # Import inside the test so the module is importable in different test layouts. + # 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) + + 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 == [] + 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(): + """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 + 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_LIBRARY) == ["ui"] + assert "@clerk/ui" not in imports + + +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 + + +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) 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_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") 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