@pradip1995/commerce-auth 1.1.8 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,92 @@
3
3
  Authentication UI and server actions: login, register, OTP, Google OAuth.
4
4
 
5
5
  ```ts
6
- import { Login, LOGIN_VIEW } from "@pradip1995/commerce-auth"
6
+ import {
7
+ Login,
8
+ LOGIN_VIEW,
9
+ GoogleLoginButton,
10
+ GoogleCallbackPage,
11
+ GoogleAuthShell,
12
+ GoogleIdentityServicesScript,
13
+ } from "@pradip1995/commerce-auth"
7
14
  ```
8
15
 
16
+ ### Google OAuth (redirect flow)
17
+
18
+ Use `GoogleLoginButton` for a "Continue with Google" CTA (login, register, modals):
19
+
20
+ ```tsx
21
+ import { GoogleLoginButton } from "@pradip1995/commerce-auth"
22
+ import { initiateGoogleAuth } from "@core/data/customer"
23
+
24
+ <GoogleLoginButton countryCode={countryCode} initiateAuth={initiateGoogleAuth} />
25
+ ```
26
+
27
+ Wire the OAuth callback route:
28
+
29
+ ```tsx
30
+ import { GoogleCallbackPage } from "@pradip1995/commerce-auth"
31
+ import { handleGoogleAuthCallback, handleGoogleCallback } from "@core/data/customer"
32
+
33
+ export default function GoogleCallbackRoute() {
34
+ return (
35
+ <GoogleCallbackPage
36
+ handleAuthCallback={handleGoogleAuthCallback}
37
+ handleCustomerCallback={handleGoogleCallback}
38
+ />
39
+ )
40
+ }
41
+ ```
42
+
43
+ ### Google One Tap (GIS SDK — corner popup)
44
+
45
+ Uses the official [Google Identity Services](https://developers.google.com/identity/gsi/web/guides/overview) SDK (`accounts.google.com/gsi/client`):
46
+
47
+ 1. `google.accounts.id.initialize({ client_id, callback })`
48
+ 2. `google.accounts.id.prompt()` — shows the account picker on any page
49
+ 3. User clicks an account → JWT credential sent to your backend
50
+ 4. Medusa verifies the token via `@medusajs/auth-google`
51
+
52
+ **Google Cloud Console**
53
+
54
+ - OAuth 2.0 Client ID (Web application)
55
+ - Authorized JavaScript origins: `http://localhost:8000`, your production domain
56
+ - OAuth consent screen with app name and branding (controls "Sign in to … with Google")
57
+
58
+ **Root layout**
59
+
60
+ ```tsx
61
+ import { retrieveCustomer } from "@core/data/customer"
62
+ import {
63
+ GoogleAuthShell,
64
+ GoogleIdentityServicesScript,
65
+ } from "@pradip1995/commerce-auth"
66
+
67
+ export default async function RootLayout({ children }) {
68
+ const customer = await retrieveCustomer().catch(() => null)
69
+ const isAuthenticated = Boolean(customer && customer.id !== "pending_deletion")
70
+
71
+ return (
72
+ <html>
73
+ <head>
74
+ {/* Step 2: Load GIS library (official docs) */}
75
+ <GoogleIdentityServicesScript />
76
+ </head>
77
+ <body>
78
+ {/* Step 3: Initialize + prompt on every page for signed-out users */}
79
+ <GoogleAuthShell isAuthenticated={isAuthenticated}>
80
+ {children}
81
+ </GoogleAuthShell>
82
+ </body>
83
+ </html>
84
+ )
85
+ }
86
+ ```
87
+
88
+ Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` (same client ID as Medusa `GOOGLE_CLIENT_ID`).
89
+
90
+ **Backend:** `authenticateGoogleCredential` POSTs the JWT to Medusa `/auth/customer/google/callback` with `{ credential }`. Medusa validates signature, `aud`, and issuer before creating a session.
91
+
92
+ Optional: `autoSelectReturningUsers` on `GoogleAuthShell` enables GIS `auto_select` for users who already granted consent.
93
+
9
94
  Consuming apps must provide `@modules/*` shims for icons and modals, or replace theme slots to wrap headless exports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pradip1995/commerce-auth",
3
- "version": "1.1.8",
3
+ "version": "2.1.0",
4
4
  "description": "Medusa storefront authentication — login, register, OTP, Google OAuth",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -25,17 +25,32 @@
25
25
  "./types/account": "./src/types/account.ts",
26
26
  "./data/customer": "./src/data/customer.ts",
27
27
  "./data/customer-registration": "./src/data/customer-registration.ts",
28
- "./data/guest": "./src/data/guest.ts"
28
+ "./data/guest": "./src/data/guest.ts",
29
+ "./components/google-login": "./src/components/google-login/index.tsx",
30
+ "./components/google-callback": "./src/components/google-callback/index.tsx",
31
+ "./util/google-auth-client": "./src/util/google-auth-client.ts",
32
+ "./components/google-oauth-provider": "./src/components/google-oauth-provider/index.tsx",
33
+ "./components/google-auth-shell": "./src/components/google-auth-shell/index.tsx",
34
+ "./components/google-identity-script": "./src/components/google-identity-script/index.tsx",
35
+ "./components/google-one-tap": "./src/components/google-one-tap/index.tsx",
36
+ "./components/google-credential-button": "./src/components/google-credential-button/index.tsx"
29
37
  },
30
38
  "peerDependencies": {
31
- "@pradip1995/commerce-core": "^1.1.8",
39
+ "@pradip1995/commerce-core": "^2.1.0",
32
40
  "@medusajs/ui": ">=4",
41
+ "@react-oauth/google": ">=0.12",
33
42
  "next": ">=15",
34
43
  "react": ">=19",
35
44
  "react-dom": ">=19"
36
45
  },
46
+ "peerDependenciesMeta": {
47
+ "@react-oauth/google": {
48
+ "optional": true
49
+ }
50
+ },
37
51
  "devDependencies": {
38
- "@pradip1995/commerce-core": "^1.1.8",
52
+ "@pradip1995/commerce-core": "^2.1.0",
53
+ "@react-oauth/google": "^0.12.2",
39
54
  "@medusajs/types": "latest",
40
55
  "@medusajs/ui": "latest",
41
56
  "@types/react": "^19",
@@ -0,0 +1,75 @@
1
+ "use client"
2
+
3
+ import {
4
+ authenticateGoogleCredential,
5
+ handleGoogleCallback,
6
+ } from "@core/data/customer"
7
+ import { usePathname } from "next/navigation"
8
+
9
+ import { GoogleOneTapLogin } from "../google-one-tap"
10
+ import { getGoogleClientId } from "../google-oauth-provider"
11
+
12
+ export type GoogleAuthShellProps = {
13
+ children: React.ReactNode
14
+ /** Pass from server layout via retrieveCustomer() */
15
+ isAuthenticated?: boolean
16
+ defaultCountryCode?: string
17
+ /** Skip One Tap UI for returning users who already consented (GIS auto_select) */
18
+ autoSelectReturningUsers?: boolean
19
+ }
20
+
21
+ function GoogleOneTapGate({
22
+ isAuthenticated,
23
+ defaultCountryCode,
24
+ autoSelectReturningUsers,
25
+ }: {
26
+ isAuthenticated: boolean
27
+ defaultCountryCode: string
28
+ autoSelectReturningUsers: boolean
29
+ }) {
30
+ const pathname = usePathname()
31
+ const clientId = getGoogleClientId()
32
+ const isCallbackRoute = pathname?.startsWith("/auth/customer/google/callback")
33
+ const disabled = !clientId || isAuthenticated || isCallbackRoute
34
+
35
+ if (disabled) {
36
+ return null
37
+ }
38
+
39
+ return (
40
+ <GoogleOneTapLogin
41
+ handlers={{
42
+ authenticateCredential: authenticateGoogleCredential,
43
+ handleCustomerCallback: handleGoogleCallback,
44
+ }}
45
+ defaultCountryCode={defaultCountryCode}
46
+ autoSelect={autoSelectReturningUsers}
47
+ useFedcmForPrompt={true}
48
+ />
49
+ )
50
+ }
51
+
52
+ export function GoogleAuthShell({
53
+ children,
54
+ isAuthenticated = false,
55
+ defaultCountryCode,
56
+ autoSelectReturningUsers = false,
57
+ }: GoogleAuthShellProps) {
58
+ const region =
59
+ defaultCountryCode ??
60
+ process.env.NEXT_PUBLIC_DEFAULT_REGION?.trim() ??
61
+ "us"
62
+
63
+ return (
64
+ <>
65
+ <GoogleOneTapGate
66
+ isAuthenticated={isAuthenticated}
67
+ defaultCountryCode={region}
68
+ autoSelectReturningUsers={autoSelectReturningUsers}
69
+ />
70
+ {children}
71
+ </>
72
+ )
73
+ }
74
+
75
+ export default GoogleAuthShell
@@ -0,0 +1,163 @@
1
+ "use client"
2
+
3
+ import { Suspense, useEffect, useRef, useState } from "react"
4
+ import { useRouter, useSearchParams } from "next/navigation"
5
+ import {
6
+ decodeJwtPayload,
7
+ GOOGLE_LOGIN_COUNTRY_CODE_KEY,
8
+ resolveCountryCode,
9
+ resolvePostGoogleAuthRedirect,
10
+ } from "../../util/google-auth-client"
11
+
12
+ export type GoogleAuthCallbackResult = {
13
+ error?: string
14
+ token?: string
15
+ }
16
+
17
+ export type GoogleCustomerCallbackResult = {
18
+ error?: string
19
+ }
20
+
21
+ export type GoogleCallbackContentProps = {
22
+ handleAuthCallback: (
23
+ params: Record<string, string>
24
+ ) => Promise<GoogleAuthCallbackResult>
25
+ handleCustomerCallback: (
26
+ token: string,
27
+ email?: string,
28
+ first_name?: string,
29
+ last_name?: string
30
+ ) => Promise<GoogleCustomerCallbackResult>
31
+ defaultCountryCode?: string
32
+ loginPath?: string
33
+ errorButtonClassName?: string
34
+ loadingMessage?: string
35
+ }
36
+
37
+ export function GoogleCallbackContent({
38
+ defaultCountryCode = "us",
39
+ loginPath = "/account",
40
+ errorButtonClassName = "px-4 py-2 account-btn-primary rounded-lg",
41
+ loadingMessage = "Processing Google authentication...",
42
+ handleAuthCallback,
43
+ handleCustomerCallback,
44
+ }: GoogleCallbackContentProps) {
45
+ const searchParams = useSearchParams()
46
+ const router = useRouter()
47
+ const [error, setError] = useState<string>()
48
+ const handledRef = useRef(false)
49
+
50
+ useEffect(() => {
51
+ if (handledRef.current) {
52
+ return
53
+ }
54
+ handledRef.current = true
55
+
56
+ const runCallback = async () => {
57
+ try {
58
+ const params: Record<string, string> = {}
59
+ searchParams.forEach((value, key) => {
60
+ params[key] = value
61
+ })
62
+
63
+ if (!params.code || !params.state) {
64
+ throw new Error("Missing authentication parameters.")
65
+ }
66
+
67
+ const authResult = await handleAuthCallback(params)
68
+
69
+ if (authResult.error) {
70
+ throw new Error(authResult.error)
71
+ }
72
+
73
+ const token = authResult.token
74
+ if (!token || typeof token !== "string") {
75
+ throw new Error("No token received.")
76
+ }
77
+
78
+ const decoded = decodeJwtPayload(token)
79
+ const userMetadata = decoded?.user_metadata as
80
+ | { name?: string; email?: string }
81
+ | undefined
82
+ const name = userMetadata?.name
83
+ const nameParts = name?.split(" ") || []
84
+
85
+ const callbackResult = await handleCustomerCallback(
86
+ token,
87
+ userMetadata?.email || (decoded?.email as string | undefined),
88
+ nameParts[0] || undefined,
89
+ nameParts.slice(1).join(" ") || undefined
90
+ )
91
+
92
+ if (callbackResult.error) {
93
+ throw new Error(callbackResult.error)
94
+ }
95
+
96
+ const savedCountryCode = localStorage.getItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY)
97
+ const countryCode = resolveCountryCode(savedCountryCode || undefined, defaultCountryCode)
98
+
99
+ if (savedCountryCode) {
100
+ localStorage.removeItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY)
101
+ }
102
+
103
+ window.location.href = resolvePostGoogleAuthRedirect(
104
+ countryCode,
105
+ defaultCountryCode
106
+ )
107
+ } catch (err) {
108
+ setError(err instanceof Error ? err.message : "Authentication failed.")
109
+ }
110
+ }
111
+
112
+ void runCallback()
113
+ }, [
114
+ defaultCountryCode,
115
+ handleAuthCallback,
116
+ handleCustomerCallback,
117
+ searchParams,
118
+ ])
119
+
120
+ if (error) {
121
+ return (
122
+ <div className="min-h-screen flex items-center justify-center">
123
+ <div className="text-center">
124
+ <p className="text-red-500 mb-4">{error}</p>
125
+ <button
126
+ type="button"
127
+ onClick={() => router.push(loginPath)}
128
+ className={errorButtonClassName}
129
+ >
130
+ Go to Login
131
+ </button>
132
+ </div>
133
+ </div>
134
+ )
135
+ }
136
+
137
+ return (
138
+ <div className="min-h-screen flex items-center justify-center">
139
+ <p>{loadingMessage}</p>
140
+ </div>
141
+ )
142
+ }
143
+
144
+ export type GoogleCallbackPageProps = GoogleCallbackContentProps & {
145
+ suspenseFallback?: React.ReactNode
146
+ }
147
+
148
+ export function GoogleCallbackPage({
149
+ suspenseFallback = (
150
+ <div className="min-h-screen flex items-center justify-center">
151
+ <p>Loading...</p>
152
+ </div>
153
+ ),
154
+ ...contentProps
155
+ }: GoogleCallbackPageProps) {
156
+ return (
157
+ <Suspense fallback={suspenseFallback}>
158
+ <GoogleCallbackContent {...contentProps} />
159
+ </Suspense>
160
+ )
161
+ }
162
+
163
+ export default GoogleCallbackPage
@@ -0,0 +1,85 @@
1
+ "use client"
2
+
3
+ import { CredentialResponse, GoogleLogin } from "@react-oauth/google"
4
+ import { useParams } from "next/navigation"
5
+ import { useCallback, useState } from "react"
6
+ import {
7
+ completeGoogleCredentialSignIn,
8
+ type GoogleCredentialSignInHandlers,
9
+ } from "../../util/google-auth-client"
10
+ import { getGoogleClientId } from "../google-oauth-provider"
11
+
12
+ export type GoogleCredentialButtonProps = {
13
+ handlers: GoogleCredentialSignInHandlers
14
+ countryCode?: string
15
+ defaultCountryCode?: string
16
+ className?: string
17
+ onError?: (message: string) => void
18
+ }
19
+
20
+ export function GoogleCredentialButton({
21
+ handlers,
22
+ countryCode: countryCodeProp,
23
+ defaultCountryCode = "us",
24
+ className,
25
+ onError,
26
+ }: GoogleCredentialButtonProps) {
27
+ const params = useParams()
28
+ const [loading, setLoading] = useState(false)
29
+ const clientId = getGoogleClientId()
30
+
31
+ const handleSuccess = useCallback(
32
+ async (response: CredentialResponse) => {
33
+ const credential = response.credential
34
+ if (!credential) {
35
+ onError?.("No Google credential received")
36
+ return
37
+ }
38
+
39
+ try {
40
+ setLoading(true)
41
+ const result = await completeGoogleCredentialSignIn(credential, handlers, {
42
+ countryCode:
43
+ countryCodeProp || (params?.countryCode as string | undefined),
44
+ defaultCountryCode,
45
+ })
46
+
47
+ if (result.error) {
48
+ onError?.(result.error)
49
+ setLoading(false)
50
+ }
51
+ } catch {
52
+ onError?.("Google sign-in failed")
53
+ setLoading(false)
54
+ }
55
+ },
56
+ [countryCodeProp, defaultCountryCode, handlers, onError, params?.countryCode]
57
+ )
58
+
59
+ if (!clientId) {
60
+ return null
61
+ }
62
+
63
+ return (
64
+ <div
65
+ className={className}
66
+ aria-busy={loading}
67
+ data-testid="google-credential-button"
68
+ >
69
+ <GoogleLogin
70
+ onSuccess={handleSuccess}
71
+ onError={() => {
72
+ onError?.("Google sign-in was cancelled")
73
+ setLoading(false)
74
+ }}
75
+ theme="outline"
76
+ size="large"
77
+ shape="pill"
78
+ width="100%"
79
+ text="continue_with"
80
+ />
81
+ </div>
82
+ )
83
+ }
84
+
85
+ export default GoogleCredentialButton
@@ -0,0 +1,23 @@
1
+ import Script from "next/script"
2
+
3
+ /**
4
+ * Loads the official Google Identity Services (GIS) SDK in `<head>`.
5
+ * @see https://developers.google.com/identity/gsi/web/guides/overview
6
+ */
7
+ export function GoogleIdentityServicesScript() {
8
+ const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID?.trim()
9
+
10
+ if (!clientId) {
11
+ return null
12
+ }
13
+
14
+ return (
15
+ <Script
16
+ id="google-identity-services"
17
+ src="https://accounts.google.com/gsi/client"
18
+ strategy="beforeInteractive"
19
+ />
20
+ )
21
+ }
22
+
23
+ export default GoogleIdentityServicesScript
@@ -0,0 +1,91 @@
1
+ "use client"
2
+
3
+ import Image from "next/image"
4
+ import { useParams } from "next/navigation"
5
+ import { useState } from "react"
6
+ import {
7
+ clearMedusaAuthCookies,
8
+ GOOGLE_LOGIN_COUNTRY_CODE_KEY,
9
+ resolveCountryCode,
10
+ } from "../../util/google-auth-client"
11
+
12
+ export type GoogleAuthInitiateResult = {
13
+ error?: string
14
+ redirectUrl?: string
15
+ }
16
+
17
+ export type GoogleLoginButtonProps = {
18
+ countryCode?: string
19
+ defaultCountryCode?: string
20
+ label?: string
21
+ loadingLabel?: string
22
+ googleIconSrc?: string
23
+ className?: string
24
+ disabled?: boolean
25
+ initiateAuth: (countryCode?: string) => Promise<GoogleAuthInitiateResult>
26
+ }
27
+
28
+ const DEFAULT_BUTTON_CLASS =
29
+ "w-full flex items-center justify-center gap-2.5 py-3 px-4 border border-gray-200 bg-white hover:bg-gray-50 transition-all shadow-sm hover:shadow-md active:scale-[0.98]"
30
+
31
+ export function GoogleLoginButton({
32
+ countryCode: countryCodeProp,
33
+ defaultCountryCode = "us",
34
+ label = "Continue with Google",
35
+ loadingLabel = "Connecting...",
36
+ googleIconSrc = "/Google.svg",
37
+ className,
38
+ disabled = false,
39
+ initiateAuth,
40
+ }: GoogleLoginButtonProps) {
41
+ const params = useParams()
42
+ const [loading, setLoading] = useState(false)
43
+
44
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
45
+ event.preventDefault()
46
+ event.stopPropagation()
47
+
48
+ try {
49
+ setLoading(true)
50
+
51
+ const countryCode = resolveCountryCode(
52
+ countryCodeProp || (params?.countryCode as string | undefined),
53
+ defaultCountryCode
54
+ )
55
+
56
+ localStorage.setItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY, countryCode)
57
+ clearMedusaAuthCookies()
58
+
59
+ const result = await initiateAuth(countryCode)
60
+
61
+ if (result.error) {
62
+ throw new Error(result.error)
63
+ }
64
+
65
+ if (result.redirectUrl) {
66
+ window.location.href = result.redirectUrl
67
+ } else {
68
+ setLoading(false)
69
+ }
70
+ } catch {
71
+ setLoading(false)
72
+ }
73
+ }
74
+
75
+ return (
76
+ <button
77
+ type="button"
78
+ onClick={handleClick}
79
+ disabled={disabled || loading}
80
+ className={className ?? DEFAULT_BUTTON_CLASS}
81
+ style={{ borderRadius: "30px" }}
82
+ >
83
+ <Image src={googleIconSrc} alt="Google" width={20} height={20} />
84
+ <span className="text-sm text-gray-700 font-bold">
85
+ {loading ? loadingLabel : label}
86
+ </span>
87
+ </button>
88
+ )
89
+ }
90
+
91
+ export default GoogleLoginButton
@@ -0,0 +1,32 @@
1
+ "use client"
2
+
3
+ import { GoogleOAuthProvider } from "@react-oauth/google"
4
+ import { useEffect, useState } from "react"
5
+
6
+ export type GoogleAuthProviderProps = {
7
+ children: React.ReactNode
8
+ clientId?: string
9
+ }
10
+
11
+ export function getGoogleClientId(): string | undefined {
12
+ return process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID?.trim() || undefined
13
+ }
14
+
15
+ export function GoogleAuthProvider({
16
+ children,
17
+ clientId = getGoogleClientId(),
18
+ }: GoogleAuthProviderProps) {
19
+ const [mounted, setMounted] = useState(false)
20
+
21
+ useEffect(() => {
22
+ setMounted(true)
23
+ }, [])
24
+
25
+ if (!clientId || !mounted) {
26
+ return <>{children}</>
27
+ }
28
+
29
+ return <GoogleOAuthProvider clientId={clientId}>{children}</GoogleOAuthProvider>
30
+ }
31
+
32
+ export default GoogleAuthProvider
@@ -0,0 +1,106 @@
1
+ "use client"
2
+
3
+ import { useParams } from "next/navigation"
4
+ import { useEffect, useRef } from "react"
5
+ import {
6
+ completeGoogleCredentialSignIn,
7
+ type GoogleCredentialSignInHandlers,
8
+ } from "../../util/google-auth-client"
9
+ import {
10
+ type GoogleCredentialResponse,
11
+ waitForGoogleIdentityServices,
12
+ } from "../../util/google-identity-services"
13
+ import { getGoogleClientId } from "../google-oauth-provider"
14
+
15
+ export type GoogleOneTapLoginProps = {
16
+ handlers: GoogleCredentialSignInHandlers
17
+ countryCode?: string
18
+ defaultCountryCode?: string
19
+ disabled?: boolean
20
+ /** Enable automatic sign-in for returning users who already granted consent */
21
+ autoSelect?: boolean
22
+ useFedcmForPrompt?: boolean
23
+ onError?: (message: string) => void
24
+ }
25
+
26
+ export function GoogleOneTapLogin({
27
+ handlers,
28
+ countryCode: countryCodeProp,
29
+ defaultCountryCode = "us",
30
+ disabled = false,
31
+ autoSelect = false,
32
+ useFedcmForPrompt = true,
33
+ onError,
34
+ }: GoogleOneTapLoginProps) {
35
+ const params = useParams()
36
+ const clientId = getGoogleClientId()
37
+ const handlersRef = useRef(handlers)
38
+ const onErrorRef = useRef(onError)
39
+
40
+ handlersRef.current = handlers
41
+ onErrorRef.current = onError
42
+
43
+ useEffect(() => {
44
+ if (disabled || !clientId) {
45
+ return
46
+ }
47
+
48
+ let cancelled = false
49
+
50
+ waitForGoogleIdentityServices()
51
+ .then((idApi) => {
52
+ if (cancelled) {
53
+ return
54
+ }
55
+
56
+ idApi.initialize({
57
+ client_id: clientId,
58
+ callback: (response: GoogleCredentialResponse) => {
59
+ const credential = response.credential
60
+ if (!credential) {
61
+ onErrorRef.current?.("No Google credential received")
62
+ return
63
+ }
64
+
65
+ completeGoogleCredentialSignIn(credential, handlersRef.current, {
66
+ countryCode:
67
+ countryCodeProp || (params?.countryCode as string | undefined),
68
+ defaultCountryCode,
69
+ }).then((result) => {
70
+ if (result.error) {
71
+ onErrorRef.current?.(result.error)
72
+ }
73
+ })
74
+ },
75
+ auto_select: autoSelect,
76
+ cancel_on_tap_outside: true,
77
+ use_fedcm_for_prompt: useFedcmForPrompt,
78
+ context: "signin",
79
+ })
80
+
81
+ idApi.prompt()
82
+ })
83
+ .catch((error: Error) => {
84
+ if (!cancelled) {
85
+ onErrorRef.current?.(error.message)
86
+ }
87
+ })
88
+
89
+ return () => {
90
+ cancelled = true
91
+ window.google?.accounts?.id?.cancel()
92
+ }
93
+ }, [
94
+ clientId,
95
+ countryCodeProp,
96
+ defaultCountryCode,
97
+ disabled,
98
+ autoSelect,
99
+ useFedcmForPrompt,
100
+ params?.countryCode,
101
+ ])
102
+
103
+ return null
104
+ }
105
+
106
+ export default GoogleOneTapLogin
@@ -1,13 +1,13 @@
1
1
  "use client"
2
2
 
3
- import { login, initiateGoogleAuth } from "@core/data/customer"
3
+ import { login } from "@core/data/customer"
4
+ import GoogleLogin from "@modules/account/components/google-login"
4
5
  import { LOGIN_VIEW } from "@core/types/account"
5
6
  import ErrorMessage from "@modules/checkout/components/error-message"
6
7
  import Envelope from "@modules/common/icons/envelope"
7
8
  import Lock from "@modules/common/icons/lock"
8
9
  import Eye from "@modules/common/icons/eye"
9
10
  import EyeOff from "@modules/common/icons/eye-off"
10
- import Image from "next/image"
11
11
  import { useEffect, useRef, useState } from "react"
12
12
  import { useRouter, useParams, useSearchParams } from "next/navigation"
13
13
  import LocalizedClientLink from "@modules/common/components/localized-client-link"
@@ -39,7 +39,6 @@ const Login = ({ setCurrentView }: Props) => {
39
39
  } = useServerAction((formData: FormData) => login(null, formData))
40
40
  const [showPassword, setShowPassword] = useState(false)
41
41
  const [loginMethod, setLoginMethod] = useState<"email" | "phone">("email")
42
- const [googleLoading, setGoogleLoading] = useState(false)
43
42
  const [showGuestModal, setShowGuestModal] = useState(false)
44
43
  const [checkingGuest, setCheckingGuest] = useState(false)
45
44
  const [showDeletionModal, setShowDeletionModal] = useState(false)
@@ -117,53 +116,7 @@ const Login = ({ setCurrentView }: Props) => {
117
116
 
118
117
  {/* Google Login at Top */}
119
118
  <div className="mb-6">
120
- <button
121
- type="button"
122
- onClick={async (e) => {
123
- e.preventDefault()
124
- e.stopPropagation()
125
- try {
126
- setGoogleLoading(true)
127
-
128
- // Save current country code before redirecting to Google OAuth
129
- const countryCode =
130
- (params?.countryCode as string) ||
131
- window.location.pathname.split("/")[1] ||
132
- "us"
133
- localStorage.setItem("googleLoginCountryCode", countryCode)
134
-
135
- document.cookie =
136
- "_medusa_jwt=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
137
- document.cookie =
138
- "_medusa_cache_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
139
-
140
- // Use server-side action instead of direct backend call
141
- const result = await initiateGoogleAuth(countryCode)
142
-
143
- if (result.error) {
144
- throw new Error(result.error)
145
- }
146
-
147
- const redirectUrl = result.redirectUrl
148
-
149
- if (redirectUrl) {
150
- window.location.href = redirectUrl
151
- } else {
152
- setGoogleLoading(false)
153
- }
154
- } catch (error) {
155
- setGoogleLoading(false)
156
- }
157
- }}
158
- disabled={googleLoading}
159
- className="w-full flex items-center justify-center gap-2.5 py-3 px-4 border border-gray-200 bg-white hover:bg-gray-50 transition-all shadow-sm hover:shadow-md active:scale-[0.98]"
160
- style={{ borderRadius: "30px" }}
161
- >
162
- <Image src="/Google.svg" alt="Google" width={20} height={20} />
163
- <span className="text-sm text-gray-700 font-bold">
164
- {googleLoading ? "Connecting..." : "Continue with Google"}
165
- </span>
166
- </button>
119
+ <GoogleLogin />
167
120
 
168
121
  {/* Quick Link at Top */}
169
122
  <div className="mt-4 text-center">
@@ -10,8 +10,7 @@ import User from "@modules/common/icons/user"
10
10
  import PhoneIcon from "@modules/common/icons/phone"
11
11
  import Eye from "@modules/common/icons/eye"
12
12
  import EyeOff from "@modules/common/icons/eye-off"
13
- import Image from "next/image"
14
- import { initiateGoogleAuth } from "@core/data/customer"
13
+ import GoogleLogin from "@modules/account/components/google-login"
15
14
  import {
16
15
  registerCustomer,
17
16
  sendCustomerOTP,
@@ -43,7 +42,6 @@ const Register = ({ setCurrentView }: Props) => {
43
42
  const params = useParams()
44
43
  const router = useRouter()
45
44
  const [showPassword, setShowPassword] = useState(false)
46
- const [googleLoading, setGoogleLoading] = useState(false)
47
45
  const [error, setError] = useState<string | null>(null)
48
46
 
49
47
  // Multi-step registration state
@@ -195,53 +193,7 @@ const Register = ({ setCurrentView }: Props) => {
195
193
 
196
194
  {/* Google Signup at Top */}
197
195
  <div className="mb-6">
198
- <button
199
- type="button"
200
- onClick={async (e) => {
201
- e.preventDefault()
202
- e.stopPropagation()
203
- try {
204
- setGoogleLoading(true)
205
-
206
- // Save current country code before redirecting to Google OAuth
207
- const countryCode =
208
- (params?.countryCode as string) ||
209
- window.location.pathname.split("/")[1] ||
210
- "us"
211
- localStorage.setItem("googleLoginCountryCode", countryCode)
212
-
213
- document.cookie =
214
- "_medusa_jwt=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
215
- document.cookie =
216
- "_medusa_cache_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
217
-
218
- // Use server-side action instead of direct backend call
219
- const result = await initiateGoogleAuth(countryCode)
220
-
221
- if (result.error) {
222
- throw new Error(result.error)
223
- }
224
-
225
- const redirectUrl = result.redirectUrl
226
-
227
- if (redirectUrl) {
228
- window.location.href = redirectUrl
229
- } else {
230
- setGoogleLoading(false)
231
- }
232
- } catch (error) {
233
- setGoogleLoading(false)
234
- }
235
- }}
236
- disabled={googleLoading}
237
- className="w-full flex items-center justify-center gap-2.5 py-3 px-4 border border-gray-200 bg-white hover:bg-gray-50 transition-all shadow-sm hover:shadow-md active:scale-[0.98]"
238
- style={{ borderRadius: "30px" }}
239
- >
240
- <Image src="/Google.svg" alt="Google" width={20} height={20} />
241
- <span className="text-sm text-gray-700 font-bold">
242
- {googleLoading ? "Connecting..." : "Continue with Google"}
243
- </span>
244
- </button>
196
+ <GoogleLogin />
245
197
 
246
198
  {/* Quick Link at Top */}
247
199
  <div className="mt-4 text-center">
@@ -462,6 +462,48 @@ export async function initiateGoogleAuth(countryCode?: string) {
462
462
  }
463
463
  }
464
464
 
465
+ export async function authenticateGoogleCredential(credential: string) {
466
+ try {
467
+ if (!credential?.trim()) {
468
+ return { error: "Missing Google credential" }
469
+ }
470
+
471
+ const backendUrl =
472
+ process.env.MEDUSA_BACKEND_URL || process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
473
+
474
+ if (!backendUrl) {
475
+ return { error: "Backend URL not configured" }
476
+ }
477
+
478
+ const response = await fetch(`${backendUrl}/auth/customer/google/callback`, {
479
+ method: "POST",
480
+ headers: {
481
+ "Content-Type": "application/json",
482
+ },
483
+ body: JSON.stringify({ credential }),
484
+ cache: "no-store",
485
+ })
486
+
487
+ const data = await response.json().catch(() => ({}))
488
+
489
+ if (!response.ok) {
490
+ const message =
491
+ typeof data?.message === "string" ? data.message : "Authentication failed"
492
+ return { error: message }
493
+ }
494
+
495
+ const token = data.token || data
496
+
497
+ if (!token || typeof token !== "string") {
498
+ return { error: "No token received" }
499
+ }
500
+
501
+ return { token }
502
+ } catch (error: any) {
503
+ return { error: error.message || "Internal server error" }
504
+ }
505
+ }
506
+
465
507
  export async function handleGoogleAuthCallback(params: Record<string, string>) {
466
508
  try {
467
509
  if (!params.code || !params.state) {
package/src/index.ts CHANGED
@@ -10,3 +10,51 @@ export { default as Login } from "./components/login"
10
10
  export { default as Register } from "./components/register"
11
11
  export { default as ForgotPassword } from "./components/forgot-password"
12
12
  export { default as LoginTemplate } from "./templates/login-template"
13
+
14
+ export {
15
+ GoogleLoginButton,
16
+ default as GoogleLogin,
17
+ } from "./components/google-login"
18
+ export type {
19
+ GoogleAuthInitiateResult,
20
+ GoogleLoginButtonProps,
21
+ } from "./components/google-login"
22
+
23
+ export {
24
+ GoogleCallbackContent,
25
+ GoogleCallbackPage,
26
+ default as GoogleCallback,
27
+ } from "./components/google-callback"
28
+ export type {
29
+ GoogleAuthCallbackResult,
30
+ GoogleCallbackContentProps,
31
+ GoogleCallbackPageProps,
32
+ GoogleCustomerCallbackResult,
33
+ } from "./components/google-callback"
34
+
35
+ export {
36
+ GoogleAuthProvider,
37
+ getGoogleClientId,
38
+ default as GoogleOAuthProvider,
39
+ } from "./components/google-oauth-provider"
40
+ export type { GoogleAuthProviderProps } from "./components/google-oauth-provider"
41
+
42
+ export { GoogleAuthShell, default as GoogleAuthRoot } from "./components/google-auth-shell"
43
+ export type { GoogleAuthShellProps } from "./components/google-auth-shell"
44
+
45
+ export {
46
+ GoogleIdentityServicesScript,
47
+ default as GoogleIdentityScript,
48
+ } from "./components/google-identity-script"
49
+
50
+ export { GoogleOneTapLogin, default as GoogleOneTap } from "./components/google-one-tap"
51
+ export type { GoogleOneTapLoginProps } from "./components/google-one-tap"
52
+
53
+ export {
54
+ GoogleCredentialButton,
55
+ default as GoogleCredentialLogin,
56
+ } from "./components/google-credential-button"
57
+ export type { GoogleCredentialButtonProps } from "./components/google-credential-button"
58
+
59
+ export type { GoogleCredentialSignInHandlers } from "./util/google-auth-client"
60
+ export { completeGoogleCredentialSignIn } from "./util/google-auth-client"
@@ -6,7 +6,6 @@ import { LOGIN_VIEW } from "@core/types/account"
6
6
  import Login from "@modules/account/components/login"
7
7
  import Register from "@modules/account/components/register"
8
8
  import ForgotPassword from "@modules/account/components/forgot-password"
9
-
10
9
  export { LOGIN_VIEW }
11
10
 
12
11
  const LoginTemplate = () => {
@@ -0,0 +1,132 @@
1
+ export const GOOGLE_LOGIN_COUNTRY_CODE_KEY = "googleLoginCountryCode"
2
+ export const LOGIN_REDIRECT_URL_KEY = "loginRedirectUrl"
3
+
4
+ export function resolveCountryCode(
5
+ countryCode?: string,
6
+ defaultCountryCode = "us"
7
+ ): string {
8
+ if (countryCode?.trim()) {
9
+ return countryCode
10
+ }
11
+
12
+ if (typeof window !== "undefined") {
13
+ const fromPath = window.location.pathname.split("/")[1]
14
+ if (fromPath) {
15
+ return fromPath
16
+ }
17
+ }
18
+
19
+ return defaultCountryCode
20
+ }
21
+
22
+ export function clearMedusaAuthCookies(): void {
23
+ if (typeof document === "undefined") {
24
+ return
25
+ }
26
+
27
+ document.cookie = "_medusa_jwt=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
28
+ document.cookie = "_medusa_cache_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
29
+ }
30
+
31
+ export function decodeJwtPayload(token: string): Record<string, unknown> | null {
32
+ try {
33
+ const payload = token.split(".")[1]
34
+ const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
35
+ return JSON.parse(decoded) as Record<string, unknown>
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ export type GoogleCredentialSignInHandlers = {
42
+ authenticateCredential: (credential: string) => Promise<{
43
+ error?: string
44
+ token?: string
45
+ }>
46
+ handleCustomerCallback: (
47
+ token: string,
48
+ email?: string,
49
+ first_name?: string,
50
+ last_name?: string
51
+ ) => Promise<{ error?: string }>
52
+ }
53
+
54
+ export async function completeGoogleCredentialSignIn(
55
+ credential: string,
56
+ handlers: GoogleCredentialSignInHandlers,
57
+ options?: {
58
+ countryCode?: string
59
+ defaultCountryCode?: string
60
+ }
61
+ ): Promise<{ error?: string }> {
62
+ const defaultCountryCode = options?.defaultCountryCode ?? "us"
63
+ const countryCode = resolveCountryCode(options?.countryCode, defaultCountryCode)
64
+
65
+ localStorage.setItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY, countryCode)
66
+ clearMedusaAuthCookies()
67
+
68
+ const authResult = await handlers.authenticateCredential(credential)
69
+
70
+ if (authResult.error) {
71
+ return { error: authResult.error }
72
+ }
73
+
74
+ const token = authResult.token
75
+ if (!token) {
76
+ return { error: "No token received" }
77
+ }
78
+
79
+ const decoded = decodeJwtPayload(token)
80
+ const userMetadata = decoded?.user_metadata as
81
+ | { name?: string; email?: string }
82
+ | undefined
83
+ const name = userMetadata?.name
84
+ const nameParts = name?.split(" ") || []
85
+
86
+ const callbackResult = await handlers.handleCustomerCallback(
87
+ token,
88
+ userMetadata?.email || (decoded?.email as string | undefined),
89
+ nameParts[0] || undefined,
90
+ nameParts.slice(1).join(" ") || undefined
91
+ )
92
+
93
+ if (callbackResult.error) {
94
+ return { error: callbackResult.error }
95
+ }
96
+
97
+ const savedCountryCode = localStorage.getItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY)
98
+ const resolvedCountryCode = resolveCountryCode(
99
+ savedCountryCode || countryCode,
100
+ defaultCountryCode
101
+ )
102
+
103
+ if (savedCountryCode) {
104
+ localStorage.removeItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY)
105
+ }
106
+
107
+ window.location.href = resolvePostGoogleAuthRedirect(
108
+ resolvedCountryCode,
109
+ defaultCountryCode
110
+ )
111
+
112
+ return {}
113
+ }
114
+
115
+ export function resolvePostGoogleAuthRedirect(
116
+ countryCode: string,
117
+ defaultCountryCode = "us"
118
+ ): string {
119
+ const redirectUrl = localStorage.getItem(LOGIN_REDIRECT_URL_KEY)
120
+ const hasKeepContext = redirectUrl?.includes("login_context=keep")
121
+
122
+ if (redirectUrl && hasKeepContext) {
123
+ localStorage.removeItem(LOGIN_REDIRECT_URL_KEY)
124
+ return redirectUrl
125
+ }
126
+
127
+ if (redirectUrl) {
128
+ localStorage.removeItem(LOGIN_REDIRECT_URL_KEY)
129
+ }
130
+
131
+ return `/${countryCode || defaultCountryCode}`
132
+ }
@@ -0,0 +1,81 @@
1
+ export const GOOGLE_IDENTITY_SCRIPT_SRC = "https://accounts.google.com/gsi/client"
2
+
3
+ export type GoogleCredentialResponse = {
4
+ credential?: string
5
+ select_by?: string
6
+ }
7
+
8
+ declare global {
9
+ interface Window {
10
+ google?: {
11
+ accounts: {
12
+ id: {
13
+ initialize: (config: Record<string, unknown>) => void
14
+ prompt: (listener?: (notification: unknown) => void) => void
15
+ cancel: () => void
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+
22
+ export function getGoogleIdentityScript(): HTMLScriptElement | null {
23
+ if (typeof document === "undefined") {
24
+ return null
25
+ }
26
+
27
+ return document.querySelector(
28
+ `script[src="${GOOGLE_IDENTITY_SCRIPT_SRC}"]`
29
+ ) as HTMLScriptElement | null
30
+ }
31
+
32
+ /** Wait until the official GIS SDK (`google.accounts.id`) is ready. */
33
+ export function waitForGoogleIdentityServices(
34
+ timeoutMs = 10000
35
+ ): Promise<NonNullable<Window["google"]>["accounts"]["id"]> {
36
+ return new Promise((resolve, reject) => {
37
+ const getIdApi = () => window.google?.accounts?.id
38
+
39
+ if (getIdApi()) {
40
+ resolve(getIdApi()!)
41
+ return
42
+ }
43
+
44
+ const existingScript = getGoogleIdentityScript()
45
+ const onScriptLoad = () => {
46
+ const idApi = getIdApi()
47
+ if (idApi) {
48
+ resolve(idApi)
49
+ }
50
+ }
51
+
52
+ if (existingScript) {
53
+ existingScript.addEventListener("load", onScriptLoad)
54
+ } else {
55
+ const script = document.createElement("script")
56
+ script.src = GOOGLE_IDENTITY_SCRIPT_SRC
57
+ script.async = true
58
+ script.defer = true
59
+ script.addEventListener("load", onScriptLoad)
60
+ script.addEventListener("error", () =>
61
+ reject(new Error("Failed to load Google Identity Services"))
62
+ )
63
+ document.head.appendChild(script)
64
+ }
65
+
66
+ const started = Date.now()
67
+ const interval = window.setInterval(() => {
68
+ const idApi = getIdApi()
69
+ if (idApi) {
70
+ window.clearInterval(interval)
71
+ resolve(idApi)
72
+ return
73
+ }
74
+
75
+ if (Date.now() - started >= timeoutMs) {
76
+ window.clearInterval(interval)
77
+ reject(new Error("Google Identity Services timed out"))
78
+ }
79
+ }, 50)
80
+ })
81
+ }
@@ -1,28 +0,0 @@
1
- /**
2
- * OAuth redirect_uri must exactly match a URI registered in Google Cloud Console.
3
- * That is the storefront callback path (often includes country code), NOT the backend API path.
4
- *
5
- * Set NEXT_PUBLIC_GOOGLE_OAUTH_CALLBACK_URL to override (must match Console exactly).
6
- */
7
- export function getGoogleOAuthCallbackUrl(countryCode?: string): string {
8
- const explicit = process.env.NEXT_PUBLIC_GOOGLE_OAUTH_CALLBACK_URL?.trim()
9
- if (explicit) {
10
- return explicit
11
- }
12
-
13
- const base =
14
- process.env.NEXT_PUBLIC_BASE_URL ||
15
- process.env.STOREFRONT_URL ||
16
- "http://localhost:8000"
17
- const normalized = base.replace(/\/$/, "")
18
- const region =
19
- countryCode?.trim().toLowerCase() ||
20
- process.env.NEXT_PUBLIC_DEFAULT_REGION?.trim().toLowerCase() ||
21
- ""
22
-
23
- if (region && /^[a-z]{2,3}$/i.test(region)) {
24
- return `${normalized}/${region}/auth/customer/google/callback`
25
- }
26
-
27
- return `${normalized}/auth/customer/google/callback`
28
- }