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

View File

@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
add cloud auto-login

View File

@@ -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 (
<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 {
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 (
<>
<hr className="bg-ui-border-base my-4" />
<Button
variant="secondary"
onClick={handleCloudLogin}
className="w-full"
disabled={isCallbackPending}
isLoading={isCallbackPending}
>
{t("auth.login.cloud")}
</Button>
</>
)
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<string, unknown>
}
// 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<string, unknown>
}
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 }
}

View File

@@ -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 (
<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">
@@ -158,9 +151,11 @@ export const Login = () => {
</Button>
</form>
</Form>
{loginAfterWidgets.map((Component, i) => {
return <Component key={i} />
})}
{[...getWidgets("login.after"), CloudAuthLogin].map(
(Component, i) => {
return <Component key={i} />
}
)}
</div>
<span className="text-ui-fg-muted txt-small my-6">
<Trans