1- import { useState } from "react" ;
1+ import { useState , useEffect , useRef } from "react" ;
22import { useMutation , useQueryClient , useQuery } from "@tanstack/react-query" ;
33import { useToast } from "@/hooks/use-toast" ;
44import { isUnauthorizedError } from "@/lib/authUtils" ;
55import { apiClient } from "@/lib/apiClient" ;
6+ import { QRCodeSVG } from "qrcode.react" ;
7+ import { isMobileDevice , getDeepLinkUrl } from "@/lib/utils/mobile-detection" ;
68import { Dialog , DialogContent , DialogHeader , DialogTitle , DialogDescription } from "@/components/ui/dialog" ;
79import { Button } from "@/components/ui/button" ;
810import { RadioGroup , RadioGroupItem } from "@/components/ui/radio-group" ;
@@ -62,6 +64,10 @@ export default function ReferenceModal({ open, onOpenChange }: ReferenceModalPro
6264 const [ selectedTarget , setSelectedTarget ] = useState < any > ( null ) ;
6365 const [ referenceText , setReferenceText ] = useState ( "" ) ;
6466 const [ referenceType , setReferenceType ] = useState ( "" ) ;
67+ const [ signingSession , setSigningSession ] = useState < { sessionId : string ; qrData : string ; expiresAt : string } | null > ( null ) ;
68+ const [ signingStatus , setSigningStatus ] = useState < "pending" | "connecting" | "signed" | "expired" | "error" | "security_violation" > ( "pending" ) ;
69+ const [ timeRemaining , setTimeRemaining ] = useState < number > ( 900 ) ; // 15 minutes in seconds
70+ const [ eventSource , setEventSource ] = useState < EventSource | null > ( null ) ;
6571 const { toast } = useToast ( ) ;
6672 const queryClient = useQueryClient ( ) ;
6773
@@ -95,15 +101,23 @@ export default function ReferenceModal({ open, onOpenChange }: ReferenceModalPro
95101 const response = await apiClient . post ( '/api/references' , data ) ;
96102 return response . data ;
97103 } ,
98- onSuccess : ( ) => {
99- toast ( {
100- title : "Reference Submitted" ,
101- description : "Your professional reference has been successfully submitted." ,
102- } ) ;
103- queryClient . invalidateQueries ( { queryKey : [ "/api/dashboard/stats" ] } ) ;
104- queryClient . invalidateQueries ( { queryKey : [ "/api/dashboard/activities" ] } ) ;
105- onOpenChange ( false ) ;
106- resetForm ( ) ;
104+ onSuccess : ( data ) => {
105+ // Reference created, now we need to sign it
106+ if ( data . signingSession ) {
107+ setSigningSession ( data . signingSession ) ;
108+ setSigningStatus ( "pending" ) ;
109+ const expiresAt = new Date ( data . signingSession . expiresAt ) ;
110+ const now = new Date ( ) ;
111+ const secondsRemaining = Math . floor ( ( expiresAt . getTime ( ) - now . getTime ( ) ) / 1000 ) ;
112+ setTimeRemaining ( Math . max ( 0 , secondsRemaining ) ) ;
113+ startSSEConnection ( data . signingSession . sessionId ) ;
114+ } else {
115+ // Fallback if no signing session (shouldn't happen)
116+ toast ( {
117+ title : "Reference Created" ,
118+ description : "Your reference has been created. Please sign it to complete." ,
119+ } ) ;
120+ }
107121 } ,
108122 onError : ( error ) => {
109123 if ( isUnauthorizedError ( error ) ) {
@@ -125,12 +139,136 @@ export default function ReferenceModal({ open, onOpenChange }: ReferenceModalPro
125139 } ,
126140 } ) ;
127141
142+ const startSSEConnection = ( sessionId : string ) => {
143+ // Prevent multiple SSE connections
144+ if ( eventSource ) {
145+ eventSource . close ( ) ;
146+ }
147+
148+ // Connect to the backend SSE endpoint for signing status
149+ const baseURL = import . meta. env . VITE_EREPUTATION_BASE_URL || "http://localhost:8765" ;
150+ const sseUrl = `${ baseURL } /api/references/signing/session/${ sessionId } /status` ;
151+
152+ const newEventSource = new EventSource ( sseUrl ) ;
153+
154+ newEventSource . onopen = ( ) => {
155+ console . log ( "SSE connection established for reference signing" ) ;
156+ } ;
157+
158+ newEventSource . onmessage = ( e ) => {
159+ try {
160+ const data = JSON . parse ( e . data ) ;
161+
162+ if ( data . type === "signed" && data . status === "completed" ) {
163+ setSigningStatus ( "signed" ) ;
164+ newEventSource . close ( ) ;
165+
166+ toast ( {
167+ title : "Reference Signed!" ,
168+ description : "Your eReference has been successfully signed and submitted." ,
169+ } ) ;
170+
171+ queryClient . invalidateQueries ( { queryKey : [ "/api/dashboard/stats" ] } ) ;
172+ queryClient . invalidateQueries ( { queryKey : [ "/api/dashboard/activities" ] } ) ;
173+
174+ // Close modal and reset after a short delay
175+ setTimeout ( ( ) => {
176+ onOpenChange ( false ) ;
177+ resetForm ( ) ;
178+ } , 1500 ) ;
179+ } else if ( data . type === "expired" ) {
180+ setSigningStatus ( "expired" ) ;
181+ newEventSource . close ( ) ;
182+ toast ( {
183+ title : "Session Expired" ,
184+ description : "The signing session has expired. Please try again." ,
185+ variant : "destructive" ,
186+ } ) ;
187+ } else if ( data . type === "security_violation" ) {
188+ setSigningStatus ( "security_violation" ) ;
189+ newEventSource . close ( ) ;
190+ toast ( {
191+ title : "eName Verification Failed" ,
192+ description : "eName verification failed. Please check your eID." ,
193+ variant : "destructive" ,
194+ } ) ;
195+ } else {
196+ console . log ( "SSE message:" , data ) ;
197+ }
198+ } catch ( error ) {
199+ console . error ( "Error parsing SSE data:" , error ) ;
200+ }
201+ } ;
202+
203+ newEventSource . onerror = ( error ) => {
204+ console . error ( "SSE connection error:" , error ) ;
205+ setSigningStatus ( "error" ) ;
206+ } ;
207+
208+ setEventSource ( newEventSource ) ;
209+ } ;
210+
211+ // Countdown timer
212+ useEffect ( ( ) => {
213+ if ( signingStatus === "pending" && timeRemaining > 0 && signingSession ) {
214+ const timer = setInterval ( ( ) => {
215+ setTimeRemaining ( prev => {
216+ if ( prev <= 1 ) {
217+ setSigningStatus ( "expired" ) ;
218+ if ( eventSource ) {
219+ eventSource . close ( ) ;
220+ }
221+ return 0 ;
222+ }
223+ return prev - 1 ;
224+ } ) ;
225+ } , 1000 ) ;
226+
227+ return ( ) => clearInterval ( timer ) ;
228+ }
229+ } , [ signingStatus , timeRemaining , signingSession , eventSource ] ) ;
230+
231+ // Cleanup on unmount
232+ useEffect ( ( ) => {
233+ return ( ) => {
234+ if ( eventSource ) {
235+ eventSource . close ( ) ;
236+ }
237+ } ;
238+ } , [ eventSource ] ) ;
239+
240+ // Reset signing state when modal closes
241+ useEffect ( ( ) => {
242+ if ( ! open ) {
243+ if ( eventSource ) {
244+ eventSource . close ( ) ;
245+ setEventSource ( null ) ;
246+ }
247+ setSigningSession ( null ) ;
248+ setSigningStatus ( "pending" ) ;
249+ setTimeRemaining ( 900 ) ;
250+ }
251+ } , [ open , eventSource ] ) ;
252+
253+ const formatTime = ( seconds : number ) : string => {
254+ const mins = Math . floor ( seconds / 60 ) ;
255+ const secs = seconds % 60 ;
256+ return `${ mins } :${ secs . toString ( ) . padStart ( 2 , '0' ) } ` ;
257+ } ;
258+
128259 const resetForm = ( ) => {
129260 setTargetType ( "" ) ;
130261 setSearchQuery ( "" ) ;
131262 setSelectedTarget ( null ) ;
132263 setReferenceText ( "" ) ;
133264 setReferenceType ( "" ) ;
265+ setSigningSession ( null ) ;
266+ setSigningStatus ( "pending" ) ;
267+ setTimeRemaining ( 900 ) ;
268+ if ( eventSource ) {
269+ eventSource . close ( ) ;
270+ setEventSource ( null ) ;
271+ }
134272 } ;
135273
136274 const handleSearchChange = ( value : string ) => {
@@ -213,7 +351,101 @@ export default function ReferenceModal({ open, onOpenChange }: ReferenceModalPro
213351 </ DialogHeader >
214352
215353 < div className = "p-3 sm:p-6 flex-1 overflow-y-auto" >
216- < div className = "space-y-4 sm:space-y-6" >
354+ { signingSession ? (
355+ // Signing Interface
356+ < div className = "flex flex-col items-center justify-center space-y-6 py-8" >
357+ < div className = "text-center" >
358+ < h3 className = "text-xl font-black text-fig mb-2" > Sign Your eReference</ h3 >
359+ < p className = "text-sm text-fig/70" >
360+ Scan this QR code with your eID Wallet to sign your eReference
361+ </ p >
362+ </ div >
363+
364+ { signingSession . qrData && (
365+ < >
366+ { isMobileDevice ( ) ? (
367+ < div className = "flex flex-col gap-4 items-center" >
368+ < a
369+ href = { getDeepLinkUrl ( signingSession . qrData ) }
370+ className = "px-6 py-3 bg-fig text-white rounded-xl hover:bg-fig/90 transition-colors text-center font-bold"
371+ >
372+ Sign eReference with eID Wallet
373+ </ a >
374+ < div className = "text-xs text-fig/70 text-center max-w-xs" >
375+ Click the button to open your eID wallet app and sign your eReference
376+ </ div >
377+ </ div >
378+ ) : (
379+ < div className = "bg-white p-4 rounded-xl border-2 border-fig/20" >
380+ < QRCodeSVG
381+ value = { signingSession . qrData }
382+ size = { 200 }
383+ level = "M"
384+ includeMargin = { true }
385+ />
386+ </ div >
387+ ) }
388+ </ >
389+ ) }
390+
391+ < div className = "space-y-2 text-center" >
392+ < div className = "flex items-center justify-center gap-2" >
393+ < svg className = "w-4 h-4 text-fig/70" fill = "currentColor" viewBox = "0 0 20 20" >
394+ < path fillRule = "evenodd" d = "M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule = "evenodd" />
395+ </ svg >
396+ < span className = "text-sm text-fig/70" >
397+ Session expires in { formatTime ( timeRemaining ) }
398+ </ span >
399+ </ div >
400+
401+ { signingStatus === "signed" && (
402+ < div className = "flex items-center justify-center gap-2 text-green-600" >
403+ < svg className = "w-5 h-5" fill = "currentColor" viewBox = "0 0 20 20" >
404+ < path fillRule = "evenodd" d = "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule = "evenodd" />
405+ </ svg >
406+ < span className = "font-bold" > Reference Signed Successfully!</ span >
407+ </ div >
408+ ) }
409+
410+ { signingStatus === "expired" && (
411+ < div className = "flex items-center justify-center gap-2 text-red-600" >
412+ < svg className = "w-5 h-5" fill = "currentColor" viewBox = "0 0 20 20" >
413+ < path fillRule = "evenodd" d = "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule = "evenodd" />
414+ </ svg >
415+ < span className = "font-bold" > Session Expired</ span >
416+ </ div >
417+ ) }
418+
419+ { signingStatus === "security_violation" && (
420+ < div className = "flex items-center justify-center gap-2 text-red-600" >
421+ < svg className = "w-5 h-5" fill = "currentColor" viewBox = "0 0 20 20" >
422+ < path fillRule = "evenodd" d = "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule = "evenodd" />
423+ </ svg >
424+ < span className = "font-bold" > eName Verification Failed</ span >
425+ </ div >
426+ ) }
427+ </ div >
428+
429+ { ( signingStatus === "expired" || signingStatus === "security_violation" || signingStatus === "error" ) && (
430+ < Button
431+ onClick = { ( ) => {
432+ setSigningSession ( null ) ;
433+ setSigningStatus ( "pending" ) ;
434+ setTimeRemaining ( 900 ) ;
435+ if ( eventSource ) {
436+ eventSource . close ( ) ;
437+ setEventSource ( null ) ;
438+ }
439+ } }
440+ className = "bg-fig hover:bg-fig/90 text-white"
441+ >
442+ Try Again
443+ </ Button >
444+ ) }
445+ </ div >
446+ ) : (
447+ // Reference Form
448+ < div className = "space-y-4 sm:space-y-6" >
217449 { /* Target Selection */ }
218450 < div >
219451 < h4 className = "text-base sm:text-lg font-black text-fig mb-3 sm:mb-4" > Select eReference Target</ h4 >
@@ -342,40 +574,62 @@ export default function ReferenceModal({ open, onOpenChange }: ReferenceModalPro
342574 { referenceText . length } / 500 characters
343575 </ div >
344576 </ div >
345- </ div >
577+ </ div >
578+ ) }
346579 </ div >
347580
348- < div className = "border-t-2 border-fig/20 p-4 sm:p-6 bg-fig-10 -m-6 mt-0 rounded-b-xl flex-shrink-0" >
349- < div className = "flex flex-col sm:flex-row gap-3" >
581+ { ! signingSession && (
582+ < div className = "border-t-2 border-fig/20 p-4 sm:p-6 bg-fig-10 -m-6 mt-0 rounded-b-xl flex-shrink-0" >
583+ < div className = "flex flex-col sm:flex-row gap-3" >
584+ < Button
585+ variant = "outline"
586+ onClick = { ( ) => onOpenChange ( false ) }
587+ disabled = { submitMutation . isPending }
588+ className = "order-2 sm:order-1 flex-1 border-2 border-fig/30 text-fig/70 hover:bg-fig-10 hover:border-fig/40 font-bold h-11 sm:h-12 opacity-80"
589+ >
590+ Cancel
591+ </ Button >
592+ < Button
593+ onClick = { handleSubmit }
594+ disabled = { submitMutation . isPending || ! targetType || ! selectedTarget || ! referenceText . trim ( ) }
595+ className = "order-1 sm:order-2 flex-1 bg-fig hover:bg-fig/90 text-white font-bold h-11 sm:h-12 shadow-lg hover:shadow-xl transition-all duration-300"
596+ >
597+ { submitMutation . isPending ? (
598+ < >
599+ < div className = "w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" > </ div >
600+ Creating...
601+ </ >
602+ ) : (
603+ < >
604+ < svg className = "w-4 h-4 mr-2" fill = "currentColor" viewBox = "0 0 20 20" >
605+ < path fillRule = "evenodd" d = "M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule = "evenodd" />
606+ </ svg >
607+ Sign & Submit eReference
608+ </ >
609+ ) }
610+ </ Button >
611+ </ div >
612+ </ div >
613+ ) }
614+
615+ { signingSession && signingStatus !== "signed" && (
616+ < div className = "border-t-2 border-fig/20 p-4 sm:p-6 bg-fig-10 -m-6 mt-0 rounded-b-xl flex-shrink-0" >
350617 < Button
351618 variant = "outline"
352- onClick = { ( ) => onOpenChange ( false ) }
353- disabled = { submitMutation . isPending }
354- className = "order-2 sm:order-1 flex-1 border-2 border-fig/30 text-fig/70 hover:bg-fig-10 hover:border-fig/40 font-bold h-11 sm:h-12 opacity-80"
619+ onClick = { ( ) => {
620+ setSigningSession ( null ) ;
621+ setSigningStatus ( "pending" ) ;
622+ if ( eventSource ) {
623+ eventSource . close ( ) ;
624+ setEventSource ( null ) ;
625+ }
626+ } }
627+ className = "w-full border-2 border-fig/30 text-fig/70 hover:bg-fig-10 hover:border-fig/40 font-bold h-11 sm:h-12"
355628 >
356629 Cancel
357630 </ Button >
358- < Button
359- onClick = { handleSubmit }
360- disabled = { submitMutation . isPending || ! targetType || ! selectedTarget || ! referenceText . trim ( ) }
361- className = "order-1 sm:order-2 flex-1 bg-fig hover:bg-fig/90 text-white font-bold h-11 sm:h-12 shadow-lg hover:shadow-xl transition-all duration-300"
362- >
363- { submitMutation . isPending ? (
364- < >
365- < div className = "w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" > </ div >
366- Submitting...
367- </ >
368- ) : (
369- < >
370- < svg className = "w-4 h-4 mr-2" fill = "currentColor" viewBox = "0 0 20 20" >
371- < path fillRule = "evenodd" d = "M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule = "evenodd" />
372- </ svg >
373- Sign & Submit eReference
374- </ >
375- ) }
376- </ Button >
377631 </ div >
378- </ div >
632+ ) }
379633 </ DialogContent >
380634 </ Dialog >
381635 ) ;
0 commit comments