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 (