Skip to content

Commit 8bb41aa

Browse files
committed
feat: add GOT callback handler
1 parent 4c407b1 commit 8bb41aa

File tree

3 files changed

+144
-12
lines changed

3 files changed

+144
-12
lines changed

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: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import type { WrapperProps } from '@docusaurus/types';
22
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
33
import type LayoutType from '@theme/Layout';
44
import Layout from '@theme-original/Layout';
5-
import { type ReactNode, useEffect } from 'react';
5+
import { type ReactNode, useCallback, useEffect } from 'react';
66

7-
import { useDebugLogger, useApiBaseUrl, useGoogleOneTapConfig, useAuthStatus } from './hooks';
7+
import {
8+
useDebugLogger,
9+
useApiBaseUrl,
10+
useGoogleOneTapConfig,
11+
useAuthStatus,
12+
useGoogleOneTapVerify,
13+
} from './hooks';
14+
import type { GoogleOneTapCredentialResponse, GoogleOneTapVerifyResponse } from './types';
815

916
type GoogleCredentialResponse = {
1017
credential: string;
@@ -16,9 +23,71 @@ export default function LayoutWrapper(props: Props): ReactNode {
1623
// Hooks must be called at the top level, outside of try-catch
1724
const { siteConfig } = useDocusaurusContext();
1825
const debugLogger = useDebugLogger(siteConfig);
19-
const apiBaseUrl = useApiBaseUrl(siteConfig);
26+
const { baseUrl: apiBaseUrl, authUrl, redirectUri } = useApiBaseUrl(siteConfig);
2027
const config = useGoogleOneTapConfig(apiBaseUrl, debugLogger);
2128
const { authStatus } = useAuthStatus(siteConfig, debugLogger);
29+
const verifyGoogleOneTap = useGoogleOneTapVerify(apiBaseUrl, debugLogger);
30+
31+
// Function to manually build Logto sign-in URL
32+
const buildSignInUrl = useCallback(
33+
({ oneTimeToken, email, isNewUser }: GoogleOneTapVerifyResponse) => {
34+
try {
35+
const signInUrl = new URL(authUrl);
36+
37+
// Standard OIDC parameters: client_id
38+
signInUrl.searchParams.set('client_id', 'admin-console');
39+
signInUrl.searchParams.set('redirect_uri', redirectUri);
40+
signInUrl.searchParams.set('first_screen', isNewUser ? 'register' : 'sign_in');
41+
42+
// Add one-time token parameters
43+
signInUrl.searchParams.set('one_time_token', oneTimeToken);
44+
signInUrl.searchParams.set('login_hint', email);
45+
46+
return signInUrl.toString();
47+
} catch (error) {
48+
debugLogger.error('Failed to build sign-in URL:', error);
49+
return null;
50+
}
51+
},
52+
[authUrl, redirectUri, debugLogger]
53+
);
54+
55+
const handleCredentialResponse = useCallback(
56+
async (response: GoogleOneTapCredentialResponse) => {
57+
debugLogger.log('handleCredentialResponse received response:', response);
58+
59+
const verifyData = await verifyGoogleOneTap(response);
60+
61+
if (verifyData) {
62+
debugLogger.log('Verification completed:', verifyData);
63+
64+
try {
65+
// Build Logto sign-in URL with one-time token
66+
const signInUrl = buildSignInUrl(verifyData);
67+
68+
if (signInUrl) {
69+
// Open sign-in URL in new tab
70+
window.open(signInUrl, '_blank', 'noopener,noreferrer');
71+
debugLogger.log('Logto sign-in URL opened in new tab with one-time token');
72+
} else {
73+
debugLogger.error('Failed to build sign-in URL');
74+
}
75+
} catch (error) {
76+
debugLogger.error('Failed to open sign-in URL:', error);
77+
}
78+
}
79+
},
80+
[verifyGoogleOneTap, debugLogger, buildSignInUrl]
81+
);
82+
83+
// Make handleCredentialResponse globally available for Google One Tap callback
84+
useEffect(() => {
85+
if (typeof window !== 'undefined') {
86+
// eslint-disable-next-line @silverhand/fp/no-mutation, no-restricted-syntax
87+
(window as unknown as Record<string, unknown>).handleCredentialResponse =
88+
handleCredentialResponse;
89+
}
90+
}, [handleCredentialResponse]);
2291

2392
debugLogger.log('config', config);
2493
debugLogger.log('authStatus', authStatus);

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)