feat: add Medusa Cloud OAuth provider (#14395)
* feat: add Medusa Cloud OAuth provider * add Cloud login button * fetch whether cloud auth is enabled through api * allow unregistered to get session * handle existing users * address PR comments * prevent double execution * a few more fixes * fix callback url * fix spelling * refresh session * 200 instead of 201 * only allow cloud identities to create user * fix condition
This commit is contained in:
@@ -24,7 +24,8 @@ export async function getViteConfig(
|
||||
const backendUrl = options.backendUrl ?? ""
|
||||
const storefrontUrl = options.storefrontUrl ?? ""
|
||||
const authType = process.env.ADMIN_AUTH_TYPE ?? undefined
|
||||
const jwtTokenStorageKey = process.env.ADMIN_JWT_TOKEN_STORAGE_KEY ?? undefined
|
||||
const jwtTokenStorageKey =
|
||||
process.env.ADMIN_JWT_TOKEN_STORAGE_KEY ?? undefined
|
||||
|
||||
const baseConfig: InlineConfig = {
|
||||
root,
|
||||
|
||||
41
packages/admin/dashboard/src/hooks/api/cloud.tsx
Normal file
41
packages/admin/dashboard/src/hooks/api/cloud.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import {
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query"
|
||||
import { sdk } from "../../lib/client"
|
||||
|
||||
export const cloudQueryKeys = {
|
||||
all: ["cloud"] as const,
|
||||
auth: () => [...cloudQueryKeys.all, "auth"] as const,
|
||||
}
|
||||
|
||||
export const useCloudAuthEnabled = (
|
||||
options?: Omit<
|
||||
UseQueryOptions<{ enabled: boolean }, FetchError>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: cloudQueryKeys.auth(),
|
||||
queryFn: async () => {
|
||||
return await sdk.client.fetch<{ enabled: boolean }>("/cloud/auth")
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useCreateCloudAuthUser = (
|
||||
options?: UseMutationOptions<void, FetchError>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await sdk.client.fetch("/cloud/auth/users", {
|
||||
method: "POST",
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./api-keys"
|
||||
export * from "./auth"
|
||||
export * from "./campaigns"
|
||||
export * from "./categories"
|
||||
export * from "./cloud"
|
||||
export * from "./collections"
|
||||
export * from "./currencies"
|
||||
export * from "./customer-groups"
|
||||
@@ -26,8 +27,8 @@ export * from "./refund-reasons"
|
||||
export * from "./regions"
|
||||
export * from "./reservations"
|
||||
export * from "./sales-channels"
|
||||
export * from "./shipping-options"
|
||||
export * from "./shipping-option-types"
|
||||
export * from "./shipping-options"
|
||||
export * from "./shipping-profiles"
|
||||
export * from "./stock-locations"
|
||||
export * from "./store"
|
||||
|
||||
@@ -12242,6 +12242,26 @@
|
||||
"prompts"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"login": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"authenticationFailed": {
|
||||
"type": "string"
|
||||
},
|
||||
"cloud": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["authenticationFailed", "cloud"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["login"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -12299,7 +12319,8 @@
|
||||
"labels",
|
||||
"fields",
|
||||
"dateTime",
|
||||
"views"
|
||||
"views",
|
||||
"auth"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -3281,5 +3281,11 @@
|
||||
"cancelText": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"authenticationFailed": "Authentication failed",
|
||||
"cloud": "Log in with Medusa Cloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button, toast } from "@medusajs/ui"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useEffect, useRef } 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 { sdk } from "../../../lib/client"
|
||||
|
||||
const CLOUD_AUTH_PROVIDER = "cloud"
|
||||
|
||||
export const CloudAuthLogin = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (hasCallbackParams && !callbackInitiated.current) {
|
||||
callbackInitiated.current = true
|
||||
handleCallback()
|
||||
}
|
||||
}, [hasCallbackParams, handleCallback])
|
||||
|
||||
const handleCloudLogin = async () => {
|
||||
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
|
||||
callback_url: `${window.location.origin}${window.location.pathname}?auth_provider=${CLOUD_AUTH_PROVIDER}`,
|
||||
})
|
||||
|
||||
if (typeof result === "object" && result.location) {
|
||||
// Redirect to Medusa Cloud for authentication
|
||||
window.location.href = result.location
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error("Unexpected login response")
|
||||
} catch {
|
||||
toast.error(t("auth.login.authenticationFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useAuthCallback = (searchParams: URLSearchParams) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { mutateAsync: createCloudAuthUser } = useCreateCloudAuthUser()
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
onSuccess: () => {
|
||||
navigate("/")
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("auth.login.authenticationFailed"))
|
||||
},
|
||||
})
|
||||
|
||||
return { handleCallback, isCallbackPending }
|
||||
}
|
||||
@@ -8,8 +8,10 @@ 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"
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -21,6 +23,7 @@ export const Login = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { getWidgets } = useExtension()
|
||||
const { data: cloudAuth } = useCloudAuthEnabled()
|
||||
|
||||
const from = location.state?.from?.pathname || "/orders"
|
||||
|
||||
@@ -70,6 +73,11 @@ 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">
|
||||
@@ -150,7 +158,7 @@ export const Login = () => {
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
{getWidgets("login.after").map((Component, i) => {
|
||||
{loginAfterWidgets.map((Component, i) => {
|
||||
return <Component key={i} />
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user