From 19f274523cda2cf13fc9194d72b9d8a71aba264c Mon Sep 17 00:00:00 2001 From: Pedro Guzman Date: Fri, 9 Jan 2026 13:26:25 +0100 Subject: [PATCH] add cloud auto-login (#14488) --- .changeset/lemon-mice-jam.md | 5 + .../login/components/cloud-auth-login.tsx | 186 +++++++++++------- .../dashboard/src/routes/login/login.tsx | 15 +- 3 files changed, 129 insertions(+), 77 deletions(-) create mode 100644 .changeset/lemon-mice-jam.md diff --git a/.changeset/lemon-mice-jam.md b/.changeset/lemon-mice-jam.md new file mode 100644 index 0000000000..6a0909ef0e --- /dev/null +++ b/.changeset/lemon-mice-jam.md @@ -0,0 +1,5 @@ +--- +"@medusajs/dashboard": patch +--- + +add cloud auto-login diff --git a/packages/admin/dashboard/src/routes/login/components/cloud-auth-login.tsx b/packages/admin/dashboard/src/routes/login/components/cloud-auth-login.tsx index d9a01b9db9..6200609c03 100644 --- a/packages/admin/dashboard/src/routes/login/components/cloud-auth-login.tsx +++ b/packages/admin/dashboard/src/routes/login/components/cloud-auth-login.tsx @@ -1,10 +1,13 @@ +import { Spinner } from "@medusajs/icons" import { Button, toast } from "@medusajs/ui" -import { useMutation } from "@tanstack/react-query" -import { useEffect, useRef } from "react" +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 { useCreateCloudAuthUser } from "../../../hooks/api/cloud" +import { + useCloudAuthEnabled, + useCreateCloudAuthUser, +} from "../../../hooks/api/cloud" import { sdk } from "../../../lib/client" const CLOUD_AUTH_PROVIDER = "cloud" @@ -12,27 +15,78 @@ 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) - // Check if we're returning from the OAuth callback - const hasCallbackParams = - searchParams.get("auth_provider") === CLOUD_AUTH_PROVIDER && - searchParams.has("code") && - searchParams.has("state") - - const callbackInitiated = useRef(false) // ref to prevent duplicate calls in React strict mode and other unmounting+mounting scenarios + const actionInitiated = useRef(false) // ref to prevent duplicate calls in React strict mode and other unmounting+mounting scenarios useEffect(() => { - if (hasCallbackParams && !callbackInitiated.current) { - callbackInitiated.current = true + if (actionInitiated.current) { + return + } + + if (isAutoLogin) { + actionInitiated.current = true + handleLogin() + } else if (isCallback) { + actionInitiated.current = true handleCallback() } - }, [hasCallbackParams, handleCallback]) + }, [isAutoLogin, isCallback, handleLogin, handleCallback]) - const handleCloudLogin = async () => { + // 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, { - // in case the admin is on a different domain, or the backend URL is set to just "/" which won't work for the callback + // 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}`, }) @@ -45,70 +99,68 @@ export const CloudAuthLogin = () => { 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") + } } - } - return ( - <> -
- - - ) + 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) - const { mutateAsync: handleCallback, isPending: isCallbackPending } = - useMutation({ - mutationFn: async () => { - let token: string - try { - const query = Object.fromEntries(searchParams) - delete query.auth_provider // BE doesn't need this + // 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") + 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") } + } - const decodedToken = decodeToken(token) as { - actor_id: string - user_metadata: Record - } + 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") + } - // If user doesn't exist, create it - if (!decodedToken?.actor_id) { - await createCloudAuthUser() + setIsPending(false) + }, [searchParams, t, createCloudAuthUser, navigate]) - // 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") - } - } - - return true - }, - onSuccess: () => { - navigate("/") - }, - onError: () => { - toast.error(t("auth.login.authenticationFailed")) - }, - }) - - return { handleCallback, isCallbackPending } + return { handleCallback, isCallbackPending: isPending } } diff --git a/packages/admin/dashboard/src/routes/login/login.tsx b/packages/admin/dashboard/src/routes/login/login.tsx index 2a2ea2373a..34982c4382 100644 --- a/packages/admin/dashboard/src/routes/login/login.tsx +++ b/packages/admin/dashboard/src/routes/login/login.tsx @@ -8,7 +8,6 @@ import * as z from "zod" import { Form } from "../../components/common/form" import AvatarBox from "../../components/common/logo-box/avatar-box" import { useSignInWithEmailPass } from "../../hooks/api" -import { useCloudAuthEnabled } from "../../hooks/api/cloud" import { isFetchError } from "../../lib/is-fetch-error" import { useExtension } from "../../providers/extension-provider" import { CloudAuthLogin } from "./components/cloud-auth-login" @@ -23,7 +22,6 @@ export const Login = () => { const location = useLocation() const navigate = useNavigate() const { getWidgets } = useExtension() - const { data: cloudAuth } = useCloudAuthEnabled() const from = location.state?.from?.pathname || "/orders" @@ -73,11 +71,6 @@ export const Login = () => { form.formState.errors.email?.message || form.formState.errors.password?.message - const loginAfterWidgets = [...getWidgets("login.after")] // cloning to avoid mutating the original array below - if (cloudAuth?.enabled) { - loginAfterWidgets.push(CloudAuthLogin) - } - return (
@@ -158,9 +151,11 @@ export const Login = () => { - {loginAfterWidgets.map((Component, i) => { - return - })} + {[...getWidgets("login.after"), CloudAuthLogin].map( + (Component, i) => { + return + } + )}