@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 +86 -1
- package/package.json +19 -4
- package/src/components/google-auth-shell/index.tsx +75 -0
- package/src/components/google-callback/index.tsx +163 -0
- package/src/components/google-credential-button/index.tsx +85 -0
- package/src/components/google-identity-script/index.tsx +23 -0
- package/src/components/google-login/index.tsx +91 -0
- package/src/components/google-oauth-provider/index.tsx +32 -0
- package/src/components/google-one-tap/index.tsx +106 -0
- package/src/components/login/index.tsx +3 -50
- package/src/components/register/index.tsx +2 -50
- package/src/data/customer.ts +42 -0
- package/src/index.ts +48 -0
- package/src/templates/login-template.tsx +0 -1
- package/src/util/google-auth-client.ts +132 -0
- package/src/util/google-identity-services.ts +81 -0
- package/src/util-google-oauth.ts +0 -28
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 {
|
|
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": "
|
|
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": "^
|
|
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": "^
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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">
|
package/src/data/customer.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/util-google-oauth.ts
DELETED
|
@@ -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
|
-
}
|