Skip to content

Commit 774e2ce

Browse files
authored
chore: force signing modal (#456)
1 parent 47efb6d commit 774e2ce

File tree

1 file changed

+291
-37
lines changed

1 file changed

+291
-37
lines changed

platforms/eReputation/client/src/components/modals/reference-modal.tsx

Lines changed: 291 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { useState } from "react";
1+
import { useState, useEffect, useRef } from "react";
22
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
33
import { useToast } from "@/hooks/use-toast";
44
import { isUnauthorizedError } from "@/lib/authUtils";
55
import { apiClient } from "@/lib/apiClient";
6+
import { QRCodeSVG } from "qrcode.react";
7+
import { isMobileDevice, getDeepLinkUrl } from "@/lib/utils/mobile-detection";
68
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
79
import { Button } from "@/components/ui/button";
810
import { 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

Comments
 (0)