Skip to content

Commit bd0ceac

Browse files
committed
feat: add GOT callback handler
1 parent 344e1f9 commit bd0ceac

File tree

3 files changed

+142
-12
lines changed

3 files changed

+142
-12
lines changed

src/theme/Layout/hooks.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,51 @@
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';
66
import { fetchGoogleOneTapConfig } from './config-fetcher';
77
import { defaultApiBaseProdUrl, defaultApiBaseDevUrl, authStatusPollInterval } from './constants';
88
import { createDebugLogger, type DebugLogger } from './debug-logger';
99
import type { GoogleOneTapConfig } from './google-one-tap';
10-
import type { SiteConfig } from './types';
10+
import type {
11+
SiteConfig,
12+
GoogleOneTapCredentialResponse,
13+
GoogleOneTapVerifyResponse,
14+
} from './types';
1115

1216
export function useDebugLogger(siteConfig: SiteConfig): DebugLogger {
1317
const isDebugMode = Boolean(siteConfig.customFields?.isDebuggingEnabled);
1418

1519
return useMemo(() => createDebugLogger(isDebugMode), [isDebugMode]);
1620
}
1721

18-
export function useApiBaseUrl(siteConfig: SiteConfig): string {
22+
export function useApiBaseUrl(siteConfig: SiteConfig): {
23+
baseUrl: string;
24+
authUrl: string;
25+
redirectUri: string;
26+
} {
1927
return useMemo(() => {
2028
const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl;
21-
return typeof logtoApiBaseUrl === 'string'
22-
? logtoApiBaseUrl
23-
: siteConfig.customFields?.isDevFeatureEnabled
24-
? defaultApiBaseDevUrl
25-
: defaultApiBaseProdUrl;
29+
const baseUrl =
30+
typeof logtoApiBaseUrl === 'string'
31+
? logtoApiBaseUrl
32+
: siteConfig.customFields?.isDevFeatureEnabled
33+
? defaultApiBaseDevUrl
34+
: defaultApiBaseProdUrl;
35+
const authUrl = `${baseUrl}/oidc/auth`;
36+
const redirectUri = `${typeof logtoApiBaseUrl === 'string' ? `${logtoApiBaseUrl}/${new URL(logtoApiBaseUrl).hostname === 'localhost' ? 'demo-app' : 'callback'}` : `${defaultApiBaseProdUrl}/callback`}`;
37+
return {
38+
baseUrl,
39+
authUrl,
40+
redirectUri,
41+
};
2642
}, [siteConfig.customFields?.logtoApiBaseUrl, siteConfig.customFields?.isDevFeatureEnabled]);
2743
}
2844

2945
export function useGoogleOneTapConfig(
3046
apiBaseUrl: string,
3147
debugLogger: DebugLogger
32-
): GoogleOneTapConfig | undefined {
48+
): Optional<GoogleOneTapConfig> {
3349
const [config, setConfig] = useState<GoogleOneTapConfig>();
3450

3551
useEffect(() => {
@@ -57,6 +73,43 @@ export function useGoogleOneTapConfig(
5773
return config;
5874
}
5975

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

src/theme/Layout/index.tsx

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,86 @@ 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 } 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 Props = WrapperProps<typeof LayoutType>;
1017

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

1986
// Safe logging with error handling
2087
try {

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)