Skip to content

Commit e565965

Browse files
committed
feat: add GOT callback handler
1 parent 1a3e917 commit e565965

File tree

4 files changed

+144
-25
lines changed

4 files changed

+144
-25
lines changed

src/theme/Layout/GoogleOneTapInitializer.tsx

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,79 @@
1-
import { type ReactNode, useEffect } from 'react';
1+
import { type ReactNode, useCallback, useEffect } from 'react';
22

33
import type { DebugLogger } from './debug-logger';
44
import type { GoogleOneTapConfig } from './google-one-tap';
5-
6-
type GoogleCredentialResponse = {
7-
credential: string;
8-
};
5+
import { useApiBaseUrl, useGoogleOneTapVerify } from './hooks';
6+
import type {
7+
SiteConfig,
8+
GoogleOneTapCredentialResponse,
9+
GoogleOneTapVerifyResponse,
10+
} from './types';
911

1012
type GoogleOneTapInitializerProps = {
1113
readonly config: GoogleOneTapConfig;
1214
readonly debugLogger: DebugLogger;
15+
readonly siteConfig: SiteConfig;
1316
};
1417

1518
export default function GoogleOneTapInitializer({
1619
config,
1720
debugLogger,
21+
siteConfig,
1822
}: GoogleOneTapInitializerProps): ReactNode {
19-
useEffect(() => {
20-
// Define global handleCredentialResponse function
21-
// eslint-disable-next-line @silverhand/fp/no-mutation
22-
window.handleCredentialResponse = (response: GoogleCredentialResponse) => {
23-
console.log('Encoded JWT ID token:', response.credential);
24-
// TODO: Send to your backend for verification
25-
};
26-
}, []);
23+
const { baseUrl: apiBaseUrl, authUrl, redirectUri } = useApiBaseUrl(siteConfig);
24+
const verifyGoogleOneTap = useGoogleOneTapVerify(apiBaseUrl, debugLogger);
25+
26+
// Function to manually build Logto sign-in URL
27+
const buildSignInUrl = useCallback(
28+
({ oneTimeToken, email, isNewUser }: GoogleOneTapVerifyResponse) => {
29+
try {
30+
const signInUrl = new URL(authUrl);
31+
32+
// Standard OIDC parameters: client_id
33+
signInUrl.searchParams.set('client_id', 'admin-console');
34+
signInUrl.searchParams.set('redirect_uri', redirectUri);
35+
signInUrl.searchParams.set('first_screen', isNewUser ? 'register' : 'sign_in');
36+
37+
// Add one-time token parameters
38+
signInUrl.searchParams.set('one_time_token', oneTimeToken);
39+
signInUrl.searchParams.set('login_hint', email);
40+
41+
return signInUrl.toString();
42+
} catch (error) {
43+
debugLogger.error('Failed to build sign-in URL:', error);
44+
return null;
45+
}
46+
},
47+
[authUrl, redirectUri, debugLogger]
48+
);
49+
50+
const handleCredentialResponse = useCallback(
51+
async (response: GoogleOneTapCredentialResponse) => {
52+
debugLogger.log('handleCredentialResponse received response:', response);
53+
54+
const verifyData = await verifyGoogleOneTap(response);
55+
56+
if (verifyData) {
57+
debugLogger.log('Verification completed:', verifyData);
58+
59+
try {
60+
// Build Logto sign-in URL with one-time token
61+
const signInUrl = buildSignInUrl(verifyData);
62+
63+
if (signInUrl) {
64+
// Open sign-in URL in new tab
65+
window.open(signInUrl, '_blank', 'noopener,noreferrer');
66+
debugLogger.log('Logto sign-in URL opened in new tab with one-time token');
67+
} else {
68+
debugLogger.error('Failed to build sign-in URL');
69+
}
70+
} catch (error) {
71+
debugLogger.error('Failed to open sign-in URL:', error);
72+
}
73+
}
74+
},
75+
[verifyGoogleOneTap, debugLogger, buildSignInUrl]
76+
);
2777

2878
useEffect(() => {
2979
if (config.oneTap?.isEnabled && window.google?.accounts.id) {
@@ -34,7 +84,7 @@ export default function GoogleOneTapInitializer({
3484
window.google.accounts.id.initialize({
3585
client_id: config.clientId,
3686
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
37-
callback: window.handleCredentialResponse!,
87+
callback: handleCredentialResponse,
3888
auto_select: config.oneTap.autoSelect,
3989
cancel_on_tap_outside: config.oneTap.closeOnTapOutside,
4090
itp_support: config.oneTap.itpSupport,

src/theme/Layout/hooks.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @silverhand/fp/no-mutation */
2-
import { condString } from '@silverhand/essentials';
2+
import { condString, type Optional } from '@silverhand/essentials';
33
import { useCallback, useEffect, useMemo, useState } from 'react';
44

55
import { createAuthStatusChecker } from './auth-status';
@@ -15,29 +15,45 @@ import {
1515
} from './constants';
1616
import { createDebugLogger, type DebugLogger } from './debug-logger';
1717
import type { GoogleOneTapConfig } from './google-one-tap';
18-
import type { SiteConfig } from './types';
18+
import type {
19+
SiteConfig,
20+
GoogleOneTapCredentialResponse,
21+
GoogleOneTapVerifyResponse,
22+
} from './types';
1923

2024
export function useDebugLogger(siteConfig: SiteConfig): DebugLogger {
2125
const isDebugMode = Boolean(siteConfig.customFields?.isDebuggingEnabled);
2226

2327
return useMemo(() => createDebugLogger(isDebugMode), [isDebugMode]);
2428
}
2529

26-
export function useApiBaseUrl(siteConfig: SiteConfig): string {
30+
export function useApiBaseUrl(siteConfig: SiteConfig): {
31+
baseUrl: string;
32+
authUrl: string;
33+
redirectUri: string;
34+
} {
2735
return useMemo(() => {
2836
const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl;
29-
return typeof logtoApiBaseUrl === 'string'
30-
? logtoApiBaseUrl
31-
: siteConfig.customFields?.isDevFeatureEnabled
32-
? defaultApiBaseDevUrl
33-
: defaultApiBaseProdUrl;
37+
const baseUrl =
38+
typeof logtoApiBaseUrl === 'string'
39+
? logtoApiBaseUrl
40+
: siteConfig.customFields?.isDevFeatureEnabled
41+
? defaultApiBaseDevUrl
42+
: defaultApiBaseProdUrl;
43+
const authUrl = `${baseUrl}/oidc/auth`;
44+
const redirectUri = `${typeof logtoApiBaseUrl === 'string' ? `${logtoApiBaseUrl}/${new URL(logtoApiBaseUrl).hostname === 'localhost' ? 'demo-app' : 'callback'}` : `${defaultApiBaseProdUrl}/callback`}`;
45+
return {
46+
baseUrl,
47+
authUrl,
48+
redirectUri,
49+
};
3450
}, [siteConfig.customFields?.logtoApiBaseUrl, siteConfig.customFields?.isDevFeatureEnabled]);
3551
}
3652

3753
export function useGoogleOneTapConfig(
3854
apiBaseUrl: string,
3955
debugLogger: DebugLogger
40-
): GoogleOneTapConfig | undefined {
56+
): Optional<GoogleOneTapConfig> {
4157
const [config, setConfig] = useState<GoogleOneTapConfig>();
4258

4359
useEffect(() => {
@@ -65,6 +81,43 @@ export function useGoogleOneTapConfig(
6581
return config;
6682
}
6783

84+
export function useGoogleOneTapVerify(
85+
apiBaseUrl: string,
86+
debugLogger: DebugLogger
87+
): (response: GoogleOneTapCredentialResponse) => Promise<Optional<GoogleOneTapVerifyResponse>> {
88+
return useCallback(
89+
async (response: GoogleOneTapCredentialResponse) => {
90+
debugLogger.log('Google One Tap credential response received:', response);
91+
92+
try {
93+
const verifyResponse = await fetch(`${apiBaseUrl}/api/google-one-tap/verify`, {
94+
method: 'POST',
95+
headers: {
96+
'Content-Type': 'application/json',
97+
Origin: window.location.origin,
98+
},
99+
body: JSON.stringify({
100+
idToken: response.credential,
101+
}),
102+
});
103+
104+
if (!verifyResponse.ok) {
105+
throw new Error(`Verification failed: ${verifyResponse.status}`);
106+
}
107+
108+
const data = await verifyResponse.json();
109+
debugLogger.log('Google One Tap verification successful:', data);
110+
111+
// eslint-disable-next-line no-restricted-syntax
112+
return data as GoogleOneTapVerifyResponse;
113+
} catch (error) {
114+
debugLogger.error('Google One Tap verification failed:', error);
115+
}
116+
},
117+
[apiBaseUrl, debugLogger]
118+
);
119+
}
120+
68121
export type AuthStatusResult = {
69122
authStatus?: boolean;
70123
authCheckError?: string;

src/theme/Layout/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
1414
// Hooks must be called at the top level, outside of try-catch
1515
const { siteConfig } = useDocusaurusContext();
1616
const debugLogger = useDebugLogger(siteConfig);
17-
const apiBaseUrl = useApiBaseUrl(siteConfig);
17+
const { baseUrl: apiBaseUrl } = useApiBaseUrl(siteConfig);
1818
const config = useGoogleOneTapConfig(apiBaseUrl, debugLogger);
1919
const { authStatus } = useAuthStatus(siteConfig, debugLogger);
2020

@@ -28,7 +28,13 @@ export default function LayoutWrapper(props: Props): ReactNode {
2828
<Layout {...props} />
2929
{authStatus === false && config?.oneTap?.isEnabled && (
3030
<BrowserOnly fallback={<div>Loading Google Sign-In...</div>}>
31-
{() => <GoogleOneTapInitializer config={config} debugLogger={debugLogger} />}
31+
{() => (
32+
<GoogleOneTapInitializer
33+
config={config}
34+
debugLogger={debugLogger}
35+
siteConfig={siteConfig}
36+
/>
37+
)}
3238
</BrowserOnly>
3339
)}
3440
</>

src/theme/Layout/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ export type SiteConfig = {
2727
};
2828
};
2929

30+
export type GoogleOneTapCredentialResponse = {
31+
credential: string;
32+
};
33+
34+
export type GoogleOneTapVerifyResponse = {
35+
oneTimeToken: string;
36+
isNewUser: boolean;
37+
email: string;
38+
};
39+
3040
export type AuthStatusGlobal = {
3141
authStatus: boolean | undefined;
3242
authCheckError: string | undefined;

0 commit comments

Comments
 (0)