add cloud auto-login (#14488)
This commit is contained in:
@@ -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,70 +99,68 @@ 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 () => {
|
||||||
let token: string
|
setIsPending(true)
|
||||||
try {
|
try {
|
||||||
const query = Object.fromEntries(searchParams)
|
let token: string
|
||||||
delete query.auth_provider // BE doesn't need this
|
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)
|
token = await sdk.auth.callback("user", CLOUD_AUTH_PROVIDER, query)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error("Authentication callback failed")
|
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 {
|
navigate("/")
|
||||||
actor_id: string
|
} catch (error) {
|
||||||
user_metadata: Record<string, unknown>
|
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
|
setIsPending(false)
|
||||||
if (!decodedToken?.actor_id) {
|
}, [searchParams, t, createCloudAuthUser, navigate])
|
||||||
await createCloudAuthUser()
|
|
||||||
|
|
||||||
// Refresh token to get the updated token with actor_id
|
return { handleCallback, isCallbackPending: isPending }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
return <Component key={i} />
|
(Component, 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
|
||||||
|
|||||||
Reference in New Issue
Block a user