@pradip1995/commerce-auth 1.1.7 → 2.0.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,53 @@
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
+ } from "@pradip1995/commerce-auth"
7
12
  ```
8
13
 
14
+ ### Google OAuth
15
+
16
+ Use `GoogleLoginButton` anywhere you need a Google sign-in CTA (login, register, modals):
17
+
18
+ ```tsx
19
+ import { GoogleLoginButton } from "@pradip1995/commerce-auth"
20
+ import { initiateGoogleAuth } from "@core/data/customer"
21
+
22
+ <GoogleLoginButton countryCode={countryCode} initiateAuth={initiateGoogleAuth} />
23
+ ```
24
+
25
+ Wire the OAuth callback route to the shared page:
26
+
27
+ ```tsx
28
+ import { GoogleCallbackPage } from "@pradip1995/commerce-auth"
29
+ import { handleGoogleAuthCallback, handleGoogleCallback } from "@core/data/customer"
30
+
31
+ export default function GoogleCallbackRoute() {
32
+ return (
33
+ <GoogleCallbackPage
34
+ handleAuthCallback={handleGoogleAuthCallback}
35
+ handleCustomerCallback={handleGoogleCallback}
36
+ />
37
+ )
38
+ }
39
+ ```
40
+
41
+ Pass your app's server actions via `initiateAuth`, `handleAuthCallback`, and `handleCustomerCallback` so client UI stays shared while data layer stays in each storefront.
42
+
43
+ ### Google One Tap (optional)
44
+
45
+ Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` (same OAuth client as the Medusa backend). Wrap the app layout:
46
+
47
+ ```tsx
48
+ import { GoogleAuthProvider } from "@pradip1995/commerce-auth"
49
+
50
+ <GoogleAuthProvider>{children}</GoogleAuthProvider>
51
+ ```
52
+
53
+ On login, `GoogleOneTapLogin` is enabled automatically when the client ID is present. Credential tokens are sent to Medusa's existing `/auth/customer/google/callback` endpoint (requires backend support for `credential` in the POST body).
54
+
9
55
  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.7",
3
+ "version": "2.0.0",
4
4
  "description": "Medusa storefront authentication — login, register, OTP, Google OAuth",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -25,17 +25,30 @@
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-one-tap": "./src/components/google-one-tap/index.tsx",
34
+ "./components/google-credential-button": "./src/components/google-credential-button/index.tsx"
29
35
  },
30
36
  "peerDependencies": {
31
- "@pradip1995/commerce-core": "^1.1.7",
37
+ "@pradip1995/commerce-core": "^2.0.0",
32
38
  "@medusajs/ui": ">=4",
39
+ "@react-oauth/google": ">=0.12",
33
40
  "next": ">=15",
34
41
  "react": ">=19",
35
42
  "react-dom": ">=19"
36
43
  },
44
+ "peerDependenciesMeta": {
45
+ "@react-oauth/google": {
46
+ "optional": true
47
+ }
48
+ },
37
49
  "devDependencies": {
38
- "@pradip1995/commerce-core": "^1.1.7",
50
+ "@pradip1995/commerce-core": "^2.0.0",
51
+ "@react-oauth/google": "^0.12.2",
39
52
  "@medusajs/types": "latest",
40
53
  "@medusajs/ui": "latest",
41
54
  "@types/react": "^19",
@@ -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,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,64 @@
1
+ "use client"
2
+
3
+ import { useGoogleOneTapLogin } from "@react-oauth/google"
4
+ import { useParams } from "next/navigation"
5
+ import { useCallback } 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 GoogleOneTapLoginProps = {
13
+ handlers: GoogleCredentialSignInHandlers
14
+ countryCode?: string
15
+ defaultCountryCode?: string
16
+ disabled?: boolean
17
+ onError?: (message: string) => void
18
+ }
19
+
20
+ export function GoogleOneTapLogin({
21
+ handlers,
22
+ countryCode: countryCodeProp,
23
+ defaultCountryCode = "us",
24
+ disabled = false,
25
+ onError,
26
+ }: GoogleOneTapLoginProps) {
27
+ const params = useParams()
28
+ const clientId = getGoogleClientId()
29
+
30
+ const handleCredential = useCallback(
31
+ async (credential: string) => {
32
+ const result = await completeGoogleCredentialSignIn(credential, handlers, {
33
+ countryCode:
34
+ countryCodeProp || (params?.countryCode as string | undefined),
35
+ defaultCountryCode,
36
+ })
37
+
38
+ if (result.error) {
39
+ onError?.(result.error)
40
+ }
41
+ },
42
+ [countryCodeProp, defaultCountryCode, handlers, onError, params?.countryCode]
43
+ )
44
+
45
+ useGoogleOneTapLogin({
46
+ disabled: disabled || !clientId,
47
+ onSuccess: async (response) => {
48
+ const credential = response.credential
49
+ if (!credential) {
50
+ onError?.("No Google credential received")
51
+ return
52
+ }
53
+
54
+ await handleCredential(credential)
55
+ },
56
+ onError: () => {
57
+ onError?.("Google One Tap was dismissed or failed")
58
+ },
59
+ })
60
+
61
+ return null
62
+ }
63
+
64
+ export default GoogleOneTapLogin
@@ -1,13 +1,20 @@
1
1
  "use client"
2
2
 
3
- import { login, initiateGoogleAuth } from "@core/data/customer"
3
+ import {
4
+ authenticateGoogleCredential,
5
+ handleGoogleCallback,
6
+ initiateGoogleAuth,
7
+ login,
8
+ } from "@core/data/customer"
9
+ import { GoogleLoginButton } from "../google-login"
10
+ import { GoogleOneTapLogin } from "../google-one-tap"
11
+ import { getGoogleClientId } from "../google-oauth-provider"
4
12
  import { LOGIN_VIEW } from "@core/types/account"
5
13
  import ErrorMessage from "@modules/checkout/components/error-message"
6
14
  import Envelope from "@modules/common/icons/envelope"
7
15
  import Lock from "@modules/common/icons/lock"
8
16
  import Eye from "@modules/common/icons/eye"
9
17
  import EyeOff from "@modules/common/icons/eye-off"
10
- import Image from "next/image"
11
18
  import { useEffect, useRef, useState } from "react"
12
19
  import { useRouter, useParams, useSearchParams } from "next/navigation"
13
20
  import LocalizedClientLink from "@modules/common/components/localized-client-link"
@@ -39,7 +46,6 @@ const Login = ({ setCurrentView }: Props) => {
39
46
  } = useServerAction((formData: FormData) => login(null, formData))
40
47
  const [showPassword, setShowPassword] = useState(false)
41
48
  const [loginMethod, setLoginMethod] = useState<"email" | "phone">("email")
42
- const [googleLoading, setGoogleLoading] = useState(false)
43
49
  const [showGuestModal, setShowGuestModal] = useState(false)
44
50
  const [checkingGuest, setCheckingGuest] = useState(false)
45
51
  const [showDeletionModal, setShowDeletionModal] = useState(false)
@@ -106,8 +112,19 @@ const Login = ({ setCurrentView }: Props) => {
106
112
  setShowGuestModal(true)
107
113
  }
108
114
 
115
+ const googleCredentialHandlers = {
116
+ authenticateCredential: authenticateGoogleCredential,
117
+ handleCustomerCallback: handleGoogleCallback,
118
+ }
119
+
109
120
  return (
110
121
  <div className="w-full flex flex-col" data-testid="login-page">
122
+ {getGoogleClientId() ? (
123
+ <GoogleOneTapLogin
124
+ handlers={googleCredentialHandlers}
125
+ countryCode={params?.countryCode as string | undefined}
126
+ />
127
+ ) : null}
111
128
  <h1 className="text-xl min-[340px]:text-2xl min-[550px]:text-3xl font-bold text-heading mb-1 min-[340px]:mb-2 text-center min-[550px]:text-left">
112
129
  Welcome Back
113
130
  </h1>
@@ -117,53 +134,10 @@ const Login = ({ setCurrentView }: Props) => {
117
134
 
118
135
  {/* Google Login at Top */}
119
136
  <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>
137
+ <GoogleLoginButton
138
+ countryCode={params?.countryCode as string | undefined}
139
+ initiateAuth={initiateGoogleAuth}
140
+ />
167
141
 
168
142
  {/* Quick Link at Top */}
169
143
  <div className="mt-4 text-center">
@@ -10,8 +10,8 @@ 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
13
  import { initiateGoogleAuth } from "@core/data/customer"
14
+ import { GoogleLoginButton } from "../google-login"
15
15
  import {
16
16
  registerCustomer,
17
17
  sendCustomerOTP,
@@ -43,7 +43,6 @@ const Register = ({ setCurrentView }: Props) => {
43
43
  const params = useParams()
44
44
  const router = useRouter()
45
45
  const [showPassword, setShowPassword] = useState(false)
46
- const [googleLoading, setGoogleLoading] = useState(false)
47
46
  const [error, setError] = useState<string | null>(null)
48
47
 
49
48
  // Multi-step registration state
@@ -195,53 +194,10 @@ const Register = ({ setCurrentView }: Props) => {
195
194
 
196
195
  {/* Google Signup at Top */}
197
196
  <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>
197
+ <GoogleLoginButton
198
+ countryCode={params?.countryCode as string | undefined}
199
+ initiateAuth={initiateGoogleAuth}
200
+ />
245
201
 
246
202
  {/* Quick Link at Top */}
247
203
  <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,43 @@ 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 { GoogleOneTapLogin, default as GoogleOneTap } from "./components/google-one-tap"
43
+ export type { GoogleOneTapLoginProps } from "./components/google-one-tap"
44
+
45
+ export {
46
+ GoogleCredentialButton,
47
+ default as GoogleCredentialLogin,
48
+ } from "./components/google-credential-button"
49
+ export type { GoogleCredentialButtonProps } from "./components/google-credential-button"
50
+
51
+ export type { GoogleCredentialSignInHandlers } from "./util/google-auth-client"
52
+ export { completeGoogleCredentialSignIn } from "./util/google-auth-client"
@@ -6,6 +6,7 @@ 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
+ import { GoogleAuthProvider } from "../components/google-oauth-provider"
9
10
 
10
11
  export { LOGIN_VIEW }
11
12
 
@@ -24,7 +25,8 @@ const LoginTemplate = () => {
24
25
  }
25
26
 
26
27
  return (
27
- <div className="min-h-screen w-full flex flex-col bg-page-bg">
28
+ <GoogleAuthProvider>
29
+ <div className="min-h-screen w-full flex flex-col bg-page-bg">
28
30
  <div className="flex-1 flex flex-col items-center justify-start px-2 min-[340px]:px-3 min-[550px]:px-4 sm:px-6 md:px-8 pt-2 min-[340px]:pt-3 min-[550px]:pt-4 sm:pt-4 md:pt-6 pb-6 min-[340px]:pb-8 min-[550px]:pb-8 sm:pb-8 md:pb-8">
29
31
  <h1 className="text-2xl min-[340px]:text-3xl min-[550px]:text-3xl sm:text-4xl md:text-4xl font-bold text-heading mb-4 min-[340px]:mb-6 min-[550px]:mb-8 sm:mb-8 md:mb-8 text-center">
30
32
  My Account
@@ -38,6 +40,7 @@ const LoginTemplate = () => {
38
40
  </div>
39
41
  </div>
40
42
  </div>
43
+ </GoogleAuthProvider>
41
44
  )
42
45
  }
43
46
 
@@ -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
+ }
@@ -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
- }