1+ /* eslint-disable @silverhand/fp/no-mutation */
12import type { WrapperProps } from '@docusaurus/types' ;
23import useDocusaurusContext from '@docusaurus/useDocusaurusContext' ;
4+ import { condString } from '@silverhand/essentials' ;
35import type LayoutType from '@theme/Layout' ;
46import Layout from '@theme-original/Layout' ;
57import type { ReactNode } from 'react' ;
68import { useEffect , useState } from 'react' ;
79import { z } from 'zod' ;
810
11+ import { AuthMessageType , type AuthStatusRequest , type AuthStatusResponse } from './types' ;
12+
913type Props = WrapperProps < typeof LayoutType > ;
1014
1115const oneTapSchema = z
@@ -34,8 +38,34 @@ const DEFAULT_API_BASE_DEV_URL = 'https://auth.logto.dev';
3438
3539export default function LayoutWrapper ( props : Props ) : ReactNode {
3640 const [ config , setConfig ] = useState < GoogleOneTapConfig | undefined > ( undefined ) ;
41+ const [ authStatus , setAuthStatus ] = useState < boolean | undefined > ( undefined ) ;
42+ const [ authCheckError , setAuthCheckError ] = useState < string | undefined > ( undefined ) ;
3743 const { siteConfig } = useDocusaurusContext ( ) ;
3844
45+ // Check if debug mode is enabled
46+ const isDebugMode = Boolean ( siteConfig . customFields ?. isDebuggingEnabled ) ;
47+
48+ // Debug logger that only logs when debug mode is enabled
49+ const debugLogger = {
50+ log : ( ...args : unknown [ ] ) => {
51+ if ( isDebugMode ) {
52+ console . log ( ...args ) ;
53+ }
54+ } ,
55+ warn : ( ...args : unknown [ ] ) => {
56+ if ( isDebugMode ) {
57+ console . warn ( ...args ) ;
58+ }
59+ } ,
60+ error : ( ...args : unknown [ ] ) => {
61+ if ( isDebugMode ) {
62+ console . error ( ...args ) ;
63+ }
64+ } ,
65+ } ;
66+
67+ debugLogger . log ( 'siteConfig.customFields' , siteConfig . customFields ) ;
68+
3969 // Get the API base URL from customFields, or use the default value if it doesn't exist
4070 const logtoApiBaseUrl = siteConfig . customFields ?. logtoApiBaseUrl ;
4171 const apiBaseUrl =
@@ -45,6 +75,201 @@ export default function LayoutWrapper(props: Props): ReactNode {
4575 ? DEFAULT_API_BASE_DEV_URL
4676 : DEFAULT_API_BASE_PROD_URL ;
4777
78+ // Get auth status checker configuration
79+ const logtoAdminConsoleUrl = siteConfig . customFields ?. logtoAdminConsoleUrl ;
80+ const enableAuthStatusCheck = siteConfig . customFields ?. enableAuthStatusCheck ;
81+
82+ const iframeSrc =
83+ typeof logtoAdminConsoleUrl === 'string'
84+ ? `${ logtoAdminConsoleUrl } /auth-status-checker${ condString ( siteConfig . customFields ?. isDebuggingEnabled && '?debug=true' ) } `
85+ : undefined ;
86+ const authStatusCheckerHost =
87+ typeof logtoAdminConsoleUrl === 'string' ? new URL ( logtoAdminConsoleUrl ) . origin : undefined ;
88+
89+ /**
90+ * Function to check admin token status via cross-domain iframe communication
91+ *
92+ * This function creates a hidden iframe, sends a message to check the admin token,
93+ * and returns a promise that resolves with the token status.
94+ *
95+ * @returns Promise that resolves to true if user has admin token, false otherwise
96+ * @throws Error if auth status checker is not configured or request fails
97+ */
98+ const checkAdminTokenStatus = async ( ) : Promise < boolean > => {
99+ return new Promise ( ( resolve , reject ) => {
100+ if ( ! logtoAdminConsoleUrl || ! enableAuthStatusCheck || ! iframeSrc ) {
101+ reject ( new Error ( 'Auth status checker not configured' ) ) ;
102+ return ;
103+ }
104+
105+ const iframe = document . createElement ( 'iframe' ) ;
106+ iframe . src = iframeSrc ;
107+
108+ if ( isDebugMode ) {
109+ // Temporarily show iframe for debugging
110+ iframe . style . position = 'fixed' ;
111+ iframe . style . top = '10px' ;
112+ iframe . style . right = '10px' ;
113+ iframe . style . width = '1000px' ;
114+ iframe . style . height = '1000px' ;
115+ iframe . style . border = '2px solid red' ;
116+ iframe . style . zIndex = '9999' ;
117+ iframe . style . backgroundColor = 'white' ;
118+ } else {
119+ iframe . style . display = 'none' ;
120+ }
121+
122+ document . body . append ( iframe ) ;
123+
124+ const requestId = Math . random ( ) . toString ( 36 ) . slice ( 7 ) ;
125+ // eslint-disable-next-line @silverhand/fp/no-let, prefer-const
126+ let timeoutId : NodeJS . Timeout ;
127+ // eslint-disable-next-line @silverhand/fp/no-let
128+ let messageHandlerAdded = false ;
129+
130+ const handleMessage = ( event : MessageEvent < AuthStatusResponse > ) => {
131+ debugLogger . log ( 'handleMessage received:' , {
132+ origin : event . origin ,
133+ expectedOrigin : authStatusCheckerHost ,
134+ data : event . data ,
135+ requestId,
136+ dataType : typeof event . data ,
137+ dataKeys : Object . keys ( event . data ) ,
138+ } ) ;
139+
140+ // Validate origin for security
141+ if ( event . origin !== authStatusCheckerHost ) {
142+ debugLogger . warn ( 'Origin mismatch:' , event . origin , 'vs' , authStatusCheckerHost ) ;
143+ return ;
144+ }
145+
146+ const { data } = event ;
147+
148+ // Validate data structure
149+ if ( typeof data !== 'object' ) {
150+ debugLogger . warn ( 'Invalid message data structure:' , data ) ;
151+ return ;
152+ }
153+
154+ if ( data . requestId !== requestId ) {
155+ debugLogger . log ( 'Request ID mismatch, ignoring message:' , data . requestId , 'vs' , requestId ) ;
156+ return ;
157+ }
158+
159+ debugLogger . log ( 'Processing valid response for request:' , requestId ) ;
160+
161+ clearTimeout ( timeoutId ) ;
162+ if ( messageHandlerAdded ) {
163+ window . removeEventListener ( 'message' , handleMessage ) ;
164+ messageHandlerAdded = false ;
165+ }
166+
167+ // In debug mode (when iframe is visible), don't remove iframe immediately
168+ const isIframeVisible = iframe . style . display !== 'none' ;
169+ if ( isIframeVisible ) {
170+ // In debug mode, delay removal to allow inspection
171+ iframe . style . border = '2px solid green' ; // Change border color to indicate success
172+ setTimeout ( ( ) => {
173+ if ( document . body . contains ( iframe ) ) {
174+ iframe . remove ( ) ;
175+ }
176+ } , 5000 ) ; // Keep iframe for 5 seconds in debug mode
177+ } else if ( document . body . contains ( iframe ) ) {
178+ iframe . remove ( ) ;
179+ }
180+
181+ switch ( data . type ) {
182+ case AuthMessageType . ADMIN_TOKEN_STATUS : {
183+ debugLogger . log ( 'Received admin token status:' , data . hasToken ) ;
184+ resolve ( data . hasToken ?? false ) ;
185+ break ;
186+ }
187+ case AuthMessageType . ADMIN_TOKEN_ERROR : {
188+ console . error ( 'Received auth error:' , data . error ) ;
189+ reject ( new Error ( data . error || 'Unknown auth error' ) ) ;
190+ break ;
191+ }
192+ }
193+ } ;
194+
195+ // Add message listener
196+ window . addEventListener ( 'message' , handleMessage ) ;
197+ messageHandlerAdded = true ;
198+
199+ iframe . addEventListener ( 'load' , ( ) => {
200+ debugLogger . log ( 'iframe loaded successfully, preparing to send message' ) ;
201+ debugLogger . log ( 'iframe details:' , {
202+ src : iframe . src ,
203+ contentWindow : ! ! iframe . contentWindow ,
204+ readyState : iframe . contentDocument ?. readyState ,
205+ } ) ;
206+
207+ // Add a longer delay to ensure iframe content is fully ready and message listeners are set up
208+ setTimeout ( ( ) => {
209+ try {
210+ const message : AuthStatusRequest = {
211+ type : AuthMessageType . CHECK_ADMIN_TOKEN ,
212+ requestId,
213+ } ;
214+
215+ debugLogger . log ( 'Sending message to iframe:' , {
216+ message,
217+ targetOrigin : authStatusCheckerHost ,
218+ iframeContentWindow : ! ! iframe . contentWindow ,
219+ } ) ;
220+
221+ iframe . contentWindow ?. postMessage ( message , authStatusCheckerHost ?? '' ) ;
222+ } catch ( error ) {
223+ clearTimeout ( timeoutId ) ;
224+ if ( messageHandlerAdded ) {
225+ window . removeEventListener ( 'message' , handleMessage ) ;
226+ messageHandlerAdded = false ;
227+ }
228+ if ( document . body . contains ( iframe ) ) {
229+ iframe . remove ( ) ;
230+ }
231+ reject (
232+ new Error (
233+ `Failed to send message to iframe: ${ error instanceof Error ? error . message : 'Unknown error' } `
234+ )
235+ ) ;
236+ }
237+ } , 5000 ) ; // Increased delay to 5 seconds to ensure iframe is fully ready
238+ } ) ;
239+
240+ // eslint-disable-next-line unicorn/prefer-add-event-listener
241+ iframe . onerror = ( ) => {
242+ clearTimeout ( timeoutId ) ;
243+ window . removeEventListener ( 'message' , handleMessage ) ;
244+ if ( document . body . contains ( iframe ) ) {
245+ iframe . remove ( ) ;
246+ }
247+ reject ( new Error ( 'iframe failed to load' ) ) ;
248+ } ;
249+
250+ // Set timeout for the request
251+ timeoutId = setTimeout ( ( ) => {
252+ window . removeEventListener ( 'message' , handleMessage ) ;
253+
254+ // In debug mode, don't remove iframe immediately on timeout
255+ const isIframeVisible = iframe . style . display !== 'none' ;
256+ if ( isIframeVisible ) {
257+ // In debug mode, keep iframe visible for inspection
258+ iframe . style . border = '2px solid orange' ; // Change border color to indicate timeout
259+ setTimeout ( ( ) => {
260+ if ( document . body . contains ( iframe ) ) {
261+ iframe . remove ( ) ;
262+ }
263+ } , 10_000 ) ; // Keep iframe for 10 seconds on timeout in debug mode
264+ } else {
265+ iframe . remove ( ) ;
266+ }
267+
268+ reject ( new Error ( 'Request timeout' ) ) ;
269+ } , 5000 ) ;
270+ } ) ;
271+ } ;
272+
48273 useEffect ( ( ) => {
49274 const fetchConfig = async ( ) => {
50275 try {
@@ -57,7 +282,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
57282 setConfig ( parsedConfig ) ;
58283 return ;
59284 } catch ( parseError ) {
60- console . error ( 'Cached config validation failed:' , parseError ) ;
285+ debugLogger . error ( 'Cached config validation failed:' , parseError ) ;
61286 }
62287 }
63288
@@ -72,24 +297,91 @@ export default function LayoutWrapper(props: Props): ReactNode {
72297 }
73298
74299 const data = await response . json ( ) ;
75-
76300 const validatedConfig = googleOneTapConfigSchema . parse ( data ) ;
77301
78302 localStorage . setItem ( CACHE_KEY , JSON . stringify ( validatedConfig ) ) ;
79303 localStorage . setItem ( CACHE_EXPIRY_KEY , String ( Date . now ( ) + CACHE_EXPIRY_TIME ) ) ;
80304
81305 setConfig ( validatedConfig ) ;
82306 } catch ( error ) {
83- console . error ( 'Error fetching or validating Google One Tap config:' , error ) ;
307+ debugLogger . error ( 'Error fetching or validating Google One Tap config:' , error ) ;
84308 }
85309 } ;
86310
87311 void fetchConfig ( ) ;
88312 } , [ apiBaseUrl ] ) ;
89313
314+ // Check auth status on component mount and set up polling
315+ useEffect ( ( ) => {
316+ if ( ! enableAuthStatusCheck || ! authStatusCheckerHost ) {
317+ return ;
318+ }
319+
320+ const performAuthCheck = async ( ) => {
321+ try {
322+ setAuthCheckError ( undefined ) ;
323+ const hasToken = await checkAdminTokenStatus ( ) ;
324+ setAuthStatus ( hasToken ) ;
325+ } catch ( error ) {
326+ debugLogger . error ( 'Failed to check auth status:' , error ) ;
327+ setAuthCheckError ( error instanceof Error ? error . message : 'Unknown error' ) ;
328+ setAuthStatus ( false ) ;
329+ }
330+ } ;
331+
332+ // Initial check
333+ void performAuthCheck ( ) ;
334+
335+ // Set up polling every 30 seconds
336+ const pollInterval = setInterval ( ( ) => {
337+ void performAuthCheck ( ) ;
338+ } , 30_000 ) ;
339+
340+ // Cleanup interval on unmount
341+ return ( ) => {
342+ clearInterval ( pollInterval ) ;
343+ } ;
344+ } , [ enableAuthStatusCheck , authStatusCheckerHost ] ) ;
345+
346+ // Expose auth status to global scope for external access
347+ useEffect ( ( ) => {
348+ if ( typeof window !== 'undefined' ) {
349+ window . __logtoAuthStatus = {
350+ authStatus,
351+ authCheckError,
352+ checkAdminTokenStatus,
353+ } ;
354+ }
355+ } , [ authStatus , authCheckError ] ) ;
356+
357+ // Debug logging in development
358+ useEffect ( ( ) => {
359+ debugLogger . log ( 'Logto Auth Status Debug Info:' , {
360+ authStatus,
361+ authCheckError,
362+ logtoAdminConsoleUrl,
363+ enableAuthStatusCheck,
364+ authStatusCheckerHost,
365+ iframeSrc,
366+ currentOrigin : window . location . origin ,
367+ } ) ;
368+ } , [
369+ authStatus ,
370+ authCheckError ,
371+ logtoAdminConsoleUrl ,
372+ enableAuthStatusCheck ,
373+ authStatusCheckerHost ,
374+ iframeSrc ,
375+ ] ) ;
376+
90377 return (
91- < >
92- { config && config . oneTap ?. isEnabled && (
378+ < Layout { ...props } >
379+ { /*
380+ * Show Google One Tap if:
381+ * 1. user is not authenticated
382+ * 2. Google One Tap is enabled
383+ */ }
384+ { authStatus === false && config && config . oneTap ?. isEnabled && (
93385 < div
94386 data-itp_support = { Boolean ( config . oneTap . itpSupport ) }
95387 data-auto_select = { Boolean ( config . oneTap . autoSelect ) }
@@ -101,7 +393,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
101393 data-context = "signin"
102394 > </ div >
103395 ) }
104- < Layout { ...props } />
105- </ >
396+ </ Layout >
106397 ) ;
107398}
399+ /* eslint-enable @silverhand/fp/no-mutation */
0 commit comments