1
+ /* eslint-disable @silverhand/fp/no-mutation */
1
2
import type { WrapperProps } from '@docusaurus/types' ;
2
3
import useDocusaurusContext from '@docusaurus/useDocusaurusContext' ;
4
+ import { condString } from '@silverhand/essentials' ;
3
5
import type LayoutType from '@theme/Layout' ;
4
6
import Layout from '@theme-original/Layout' ;
5
7
import type { ReactNode } from 'react' ;
6
8
import { useEffect , useState } from 'react' ;
7
9
import { z } from 'zod' ;
8
10
11
+ import { AuthMessageType , type AuthStatusRequest , type AuthStatusResponse } from './types' ;
12
+
9
13
type Props = WrapperProps < typeof LayoutType > ;
10
14
11
15
const oneTapSchema = z
@@ -34,8 +38,34 @@ const DEFAULT_API_BASE_DEV_URL = 'https://auth.logto.dev';
34
38
35
39
export default function LayoutWrapper ( props : Props ) : ReactNode {
36
40
const [ config , setConfig ] = useState < GoogleOneTapConfig | undefined > ( undefined ) ;
41
+ const [ authStatus , setAuthStatus ] = useState < boolean | undefined > ( undefined ) ;
42
+ const [ authCheckError , setAuthCheckError ] = useState < string | undefined > ( undefined ) ;
37
43
const { siteConfig } = useDocusaurusContext ( ) ;
38
44
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
+
39
69
// Get the API base URL from customFields, or use the default value if it doesn't exist
40
70
const logtoApiBaseUrl = siteConfig . customFields ?. logtoApiBaseUrl ;
41
71
const apiBaseUrl =
@@ -45,6 +75,201 @@ export default function LayoutWrapper(props: Props): ReactNode {
45
75
? DEFAULT_API_BASE_DEV_URL
46
76
: DEFAULT_API_BASE_PROD_URL ;
47
77
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
+
48
273
useEffect ( ( ) => {
49
274
const fetchConfig = async ( ) => {
50
275
try {
@@ -57,7 +282,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
57
282
setConfig ( parsedConfig ) ;
58
283
return ;
59
284
} catch ( parseError ) {
60
- console . error ( 'Cached config validation failed:' , parseError ) ;
285
+ debugLogger . error ( 'Cached config validation failed:' , parseError ) ;
61
286
}
62
287
}
63
288
@@ -72,24 +297,91 @@ export default function LayoutWrapper(props: Props): ReactNode {
72
297
}
73
298
74
299
const data = await response . json ( ) ;
75
-
76
300
const validatedConfig = googleOneTapConfigSchema . parse ( data ) ;
77
301
78
302
localStorage . setItem ( CACHE_KEY , JSON . stringify ( validatedConfig ) ) ;
79
303
localStorage . setItem ( CACHE_EXPIRY_KEY , String ( Date . now ( ) + CACHE_EXPIRY_TIME ) ) ;
80
304
81
305
setConfig ( validatedConfig ) ;
82
306
} 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 ) ;
84
308
}
85
309
} ;
86
310
87
311
void fetchConfig ( ) ;
88
312
} , [ apiBaseUrl ] ) ;
89
313
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
+
90
377
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 && (
93
385
< div
94
386
data-itp_support = { Boolean ( config . oneTap . itpSupport ) }
95
387
data-auto_select = { Boolean ( config . oneTap . autoSelect ) }
@@ -101,7 +393,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
101
393
data-context = "signin"
102
394
> </ div >
103
395
) }
104
- < Layout { ...props } />
105
- </ >
396
+ </ Layout >
106
397
) ;
107
398
}
399
+ /* eslint-enable @silverhand/fp/no-mutation */
0 commit comments