add cloud auto-login (#14488)

This commit is contained in:
Pedro Guzman
2026-01-09 13:26:25 +01:00
committed by GitHub
parent 04b92a2f1a
commit 19f274523c
3 changed files with 129 additions and 77 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
add cloud auto-login
@@ -1,10 +1,13 @@
import { Spinner } from "@medusajs/icons"
import { Button, toast } from "@medusajs/ui" import { Button, toast } from "@medusajs/ui"
import { useMutation } from "@tanstack/react-query" import { useCallback, useEffect, useRef, useState } from "react"
import { useEffect, useRef } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { decodeToken } from "react-jwt" import { decodeToken } from "react-jwt"
import { useNavigate, useSearchParams } from "react-router-dom" 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" import { sdk } from "../../../lib/client"
const CLOUD_AUTH_PROVIDER = "cloud" const CLOUD_AUTH_PROVIDER = "cloud"
@@ -12,27 +15,78 @@ const CLOUD_AUTH_PROVIDER = "cloud"
export const CloudAuthLogin = () => { export const CloudAuthLogin = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [searchParams] = useSearchParams() 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 { handleCallback, isCallbackPending } = useAuthCallback(searchParams)
// Check if we're returning from the OAuth callback const actionInitiated = useRef(false) // ref to prevent duplicate calls in React strict mode and other unmounting+mounting scenarios
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
useEffect(() => { useEffect(() => {
if (hasCallbackParams && !callbackInitiated.current) { if (actionInitiated.current) {
callbackInitiated.current = true return
}
if (isAutoLogin) {
actionInitiated.current = true
handleLogin()
} else if (isCallback) {
actionInitiated.current = true
handleCallback() 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 (
<div className="bg-ui-bg-subtle fixed inset-0 z-50 flex items-center justify-center">
<Spinner className="text-ui-fg-subtle animate-spin" />
</div>
)
}
// 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 (
<>
<hr className="bg-ui-border-base my-4" />
<Button
variant="secondary"
onClick={handleLogin}
className="w-full"
disabled={isLoginPending || isCallbackPending}
isLoading={isLoginPending || isCallbackPending}
>
{t("auth.login.cloud")}
</Button>
</>
)
}
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 { try {
const result = await sdk.auth.login("user", CLOUD_AUTH_PROVIDER, { 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}`, callback_url: `${window.location.origin}${window.location.pathname}?auth_provider=${CLOUD_AUTH_PROVIDER}`,
}) })
@@ -45,33 +99,30 @@ export const CloudAuthLogin = () => {
throw new Error("Unexpected login response") throw new Error("Unexpected login response")
} catch { } catch {
toast.error(t("auth.login.authenticationFailed")) 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])
<hr className="bg-ui-border-base my-4" />
<Button return { handleLogin, isLoginPending: isPending }
variant="secondary"
onClick={handleCloudLogin}
className="w-full"
disabled={isCallbackPending}
isLoading={isCallbackPending}
>
{t("auth.login.cloud")}
</Button>
</>
)
} }
const useAuthCallback = (searchParams: URLSearchParams) => { const useAuthCallback = (searchParams: URLSearchParams) => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { mutateAsync: createCloudAuthUser } = useCreateCloudAuthUser() const { mutateAsync: createCloudAuthUser } = useCreateCloudAuthUser()
const [isPending, setIsPending] = useState(false)
const { mutateAsync: handleCallback, isPending: isCallbackPending } = // Not using useMutation from @tanstack/react-query because it doesn't play well with strict mode when invoked only once from a useEffect.
useMutation({ // 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.
mutationFn: async () => { const handleCallback = useCallback(async () => {
setIsPending(true)
try {
let token: string let token: string
try { try {
const query = Object.fromEntries(searchParams) const query = Object.fromEntries(searchParams)
@@ -100,15 +151,16 @@ const useAuthCallback = (searchParams: URLSearchParams) => {
} }
} }
return true
},
onSuccess: () => {
navigate("/") navigate("/")
}, } catch (error) {
onError: () => {
toast.error(t("auth.login.authenticationFailed")) 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")
}
return { handleCallback, isCallbackPending } setIsPending(false)
}, [searchParams, t, createCloudAuthUser, navigate])
return { handleCallback, isCallbackPending: isPending }
} }
@@ -8,7 +8,6 @@ import * as z from "zod"
import { Form } from "../../components/common/form" import { Form } from "../../components/common/form"
import AvatarBox from "../../components/common/logo-box/avatar-box" import AvatarBox from "../../components/common/logo-box/avatar-box"
import { useSignInWithEmailPass } from "../../hooks/api" import { useSignInWithEmailPass } from "../../hooks/api"
import { useCloudAuthEnabled } from "../../hooks/api/cloud"
import { isFetchError } from "../../lib/is-fetch-error" import { isFetchError } from "../../lib/is-fetch-error"
import { useExtension } from "../../providers/extension-provider" import { useExtension } from "../../providers/extension-provider"
import { CloudAuthLogin } from "./components/cloud-auth-login" import { CloudAuthLogin } from "./components/cloud-auth-login"
@@ -23,7 +22,6 @@ export const Login = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { getWidgets } = useExtension() const { getWidgets } = useExtension()
const { data: cloudAuth } = useCloudAuthEnabled()
const from = location.state?.from?.pathname || "/orders" const from = location.state?.from?.pathname || "/orders"
@@ -73,11 +71,6 @@ export const Login = () => {
form.formState.errors.email?.message || form.formState.errors.email?.message ||
form.formState.errors.password?.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 ( return (
<div className="bg-ui-bg-subtle flex min-h-dvh w-dvw items-center justify-center"> <div className="bg-ui-bg-subtle flex min-h-dvh w-dvw items-center justify-center">
<div className="m-4 flex w-full max-w-[280px] flex-col items-center"> <div className="m-4 flex w-full max-w-[280px] flex-col items-center">
@@ -158,9 +151,11 @@ export const Login = () => {
</Button> </Button>
</form> </form>
</Form> </Form>
{loginAfterWidgets.map((Component, i) => { {[...getWidgets("login.after"), CloudAuthLogin].map(
(Component, i) => {
return <Component key={i} /> return <Component key={i} />
})} }
)}
</div> </div>
<span className="text-ui-fg-muted txt-small my-6"> <span className="text-ui-fg-muted txt-small my-6">
<Trans <Trans