import { Spinner } from "@medusajs/icons" import { Button, toast } from "@medusajs/ui" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { decodeToken } from "react-jwt" import { useNavigate, useSearchParams } from "react-router-dom" import { useCloudAuthEnabled, useCreateCloudAuthUser, } from "../../../hooks/api/cloud" import { sdk } from "../../../lib/client" const CLOUD_AUTH_PROVIDER = "cloud" export const CloudAuthLogin = () => { const { t } = useTranslation() const [searchParams] = useSearchParams() const { data: cloudAuth } = useCloudAuthEnabled() const isAutoLogin = searchParams.get("auth_provider") === CLOUD_AUTH_PROVIDER && searchParams.get("auto") === "true" const isCallback = searchParams.get("auth_provider") === CLOUD_AUTH_PROVIDER && (searchParams.has("code") || searchParams.has("error")) const { handleLogin, isLoginPending } = useHandleLogin(isAutoLogin) const { handleCallback, isCallbackPending } = useAuthCallback(searchParams) const actionInitiated = useRef(false) // ref to prevent duplicate calls in React strict mode and other unmounting+mounting scenarios useEffect(() => { if (actionInitiated.current) { return } if (isAutoLogin) { actionInitiated.current = true handleLogin() } else if (isCallback) { actionInitiated.current = true handleCallback() } }, [isAutoLogin, isCallback, handleLogin, handleCallback]) // Render full-screen overlay during auto-login or callback to hide the login form if (isAutoLogin || isCallback) { return (
) } // This check is last on purpose. // If it was first, the /app/login form would show briefly before being replaced by the above spinner. if (!cloudAuth?.enabled) { return null } // If it's not auto-login or callback, and the cloud auth is enabled, just show the login button. return ( <>
) } const useHandleLogin = (isAutoLogin: boolean) => { const { t } = useTranslation() const navigate = useNavigate() const [isPending, setIsPending] = useState(false) // Not using useMutation from @tanstack/react-query because it doesn't play well with strict mode when invoked only once from a useEffect. // The issue is that the first instance of the mutation is invoked but quickly canceled upon the second mounting of the component, and its status gets stuck at pending. const handleLogin = useCallback(async () => { setIsPending(true) try { const result = await sdk.auth.login("user", CLOUD_AUTH_PROVIDER, { // setting callback_url in case the admin is on a different domain, or the backend URL is set to just "/" which won't work for the callback callback_url: `${window.location.origin}${window.location.pathname}?auth_provider=${CLOUD_AUTH_PROVIDER}`, }) if (typeof result === "object" && result.location) { // Redirect to Medusa Cloud for authentication window.location.href = result.location return } throw new Error("Unexpected login response") } catch { toast.error(t("auth.login.authenticationFailed")) if (isAutoLogin) { // Navigate to /login without query string cause otherwise a failed auto-login would get stuck on the spinner. // There's no point in using the query string anyway because the auto-login would just fail again. navigate("/login") } } setIsPending(false) }, [t, navigate, isAutoLogin]) return { handleLogin, isLoginPending: isPending } } const useAuthCallback = (searchParams: URLSearchParams) => { const { t } = useTranslation() const navigate = useNavigate() const { mutateAsync: createCloudAuthUser } = useCreateCloudAuthUser() const [isPending, setIsPending] = useState(false) // Not using useMutation from @tanstack/react-query because it doesn't play well with strict mode when invoked only once from a useEffect. // The issue is that the first instance of the mutation is invoked but quickly canceled upon the second mounting of the component, and its status gets stuck at pending. const handleCallback = useCallback(async () => { setIsPending(true) try { let token: string try { const query = Object.fromEntries(searchParams) delete query.auth_provider // BE doesn't need this token = await sdk.auth.callback("user", CLOUD_AUTH_PROVIDER, query) } catch (error) { throw new Error("Authentication callback failed") } const decodedToken = decodeToken(token) as { actor_id: string user_metadata: Record } // If user doesn't exist, create it if (!decodedToken?.actor_id) { await createCloudAuthUser() // Refresh token to get the updated token with actor_id const refreshedToken = await sdk.auth.refresh({ Authorization: `Bearer ${token}`, // passing it manually in case the auth type is session }) if (!refreshedToken) { throw new Error("Failed to refresh token after user creation") } } navigate("/") } catch (error) { toast.error(t("auth.login.authenticationFailed")) // Navigate to /login without query string cause otherwise a failed callback would get stuck on the spinner. // There's no point in using the query string anyway because the callback would just fail again. navigate("/login") } setIsPending(false) }, [searchParams, t, createCloudAuthUser, navigate]) return { handleCallback, isCallbackPending: isPending } }