@redacto.io/consent-sdk-react 3.5.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +6 -0
- package/dist/index.d.mts +49 -2
- package/dist/index.d.ts +49 -2
- package/dist/index.js +1785 -243
- package/dist/index.mjs +1784 -243
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +201 -118
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1192 -93
- package/src/RedactoNoticeConsent/api/index.ts +333 -206
- package/src/RedactoNoticeConsent/api/types.ts +138 -16
- package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +536 -0
- package/src/RedactoNoticeConsent/components/index.ts +7 -0
- package/src/RedactoNoticeConsent/injectStyles.ts +11 -0
- package/src/RedactoNoticeConsent/services/storage.ts +122 -0
- package/src/RedactoNoticeConsent/types.ts +2 -0
- package/src/index.ts +10 -0
|
@@ -14,8 +14,9 @@ import {
|
|
|
14
14
|
clearApiCache,
|
|
15
15
|
fetchConsentContent,
|
|
16
16
|
fetchTTSAudioUrls,
|
|
17
|
+
initiateGuardianVerification,
|
|
17
18
|
submitConsentEvent,
|
|
18
|
-
|
|
19
|
+
verifyGuardianStatus,
|
|
19
20
|
} from "./api";
|
|
20
21
|
|
|
21
22
|
// Type imports
|
|
@@ -37,6 +38,9 @@ import { injectCheckboxStyles } from "./injectStyles";
|
|
|
37
38
|
import { styles } from "./styles";
|
|
38
39
|
import { useMediaQuery } from "./useMediaQuery";
|
|
39
40
|
|
|
41
|
+
// Storage service for redirect flow
|
|
42
|
+
import { clearConsentState, getConsentState, saveConsentState } from "./services/storage";
|
|
43
|
+
|
|
40
44
|
// =============================================================================
|
|
41
45
|
// SUB-COMPONENTS
|
|
42
46
|
// =============================================================================
|
|
@@ -355,6 +359,13 @@ const PurposeItem: React.FC<
|
|
|
355
359
|
*
|
|
356
360
|
* Displays a form for collecting guardian information for minor consent.
|
|
357
361
|
*/
|
|
362
|
+
const RELATIONSHIP_OPTIONS = [
|
|
363
|
+
{ value: "Father", label: "Father" },
|
|
364
|
+
{ value: "Mother", label: "Mother" },
|
|
365
|
+
{ value: "Legal Guardian", label: "Legal Guardian" },
|
|
366
|
+
{ value: "Grandparent", label: "Grandparent" },
|
|
367
|
+
];
|
|
368
|
+
|
|
358
369
|
const GuardianForm: React.FC<{
|
|
359
370
|
formData: {
|
|
360
371
|
guardianName: string;
|
|
@@ -390,39 +401,144 @@ const GuardianForm: React.FC<{
|
|
|
390
401
|
declineButtonStyle,
|
|
391
402
|
logoUrl,
|
|
392
403
|
}) => {
|
|
404
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
405
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
406
|
+
const [openUpward, setOpenUpward] = useState(false);
|
|
407
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
408
|
+
const optionsRef = useRef<HTMLDivElement>(null);
|
|
409
|
+
|
|
410
|
+
// Close dropdown on outside click
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (!isDropdownOpen) return;
|
|
413
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
414
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
415
|
+
setIsDropdownOpen(false);
|
|
416
|
+
setFocusedIndex(-1);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
420
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
421
|
+
}, [isDropdownOpen]);
|
|
422
|
+
|
|
423
|
+
// Determine direction on open
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (!isDropdownOpen || !dropdownRef.current) return;
|
|
426
|
+
const rect = dropdownRef.current.getBoundingClientRect();
|
|
427
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
428
|
+
const dropdownHeight = 220; // approximate max height of options panel
|
|
429
|
+
setOpenUpward(spaceBelow < dropdownHeight && rect.top > dropdownHeight);
|
|
430
|
+
}, [isDropdownOpen]);
|
|
431
|
+
|
|
432
|
+
const handleSelect = (value: string) => {
|
|
433
|
+
onChange("guardianRelationship", value);
|
|
434
|
+
setIsDropdownOpen(false);
|
|
435
|
+
setFocusedIndex(-1);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
439
|
+
if (!isDropdownOpen) {
|
|
440
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
441
|
+
e.preventDefault();
|
|
442
|
+
setIsDropdownOpen(true);
|
|
443
|
+
setFocusedIndex(0);
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
switch (e.key) {
|
|
448
|
+
case "ArrowDown":
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
setFocusedIndex((prev) => Math.min(prev + 1, RELATIONSHIP_OPTIONS.length - 1));
|
|
451
|
+
break;
|
|
452
|
+
case "ArrowUp":
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
setFocusedIndex((prev) => Math.max(prev - 1, 0));
|
|
455
|
+
break;
|
|
456
|
+
case "Enter":
|
|
457
|
+
case " ":
|
|
458
|
+
e.preventDefault();
|
|
459
|
+
if (focusedIndex >= 0) handleSelect(RELATIONSHIP_OPTIONS[focusedIndex].value);
|
|
460
|
+
break;
|
|
461
|
+
case "Escape":
|
|
462
|
+
case "Tab":
|
|
463
|
+
setIsDropdownOpen(false);
|
|
464
|
+
setFocusedIndex(-1);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Scroll focused option into view
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
if (focusedIndex >= 0 && optionsRef.current) {
|
|
472
|
+
const option = optionsRef.current.children[focusedIndex] as HTMLElement;
|
|
473
|
+
option?.scrollIntoView?.({ block: "nearest" });
|
|
474
|
+
}
|
|
475
|
+
}, [focusedIndex]);
|
|
476
|
+
|
|
477
|
+
const borderColor = settings?.borderColor || "#d0d5dd";
|
|
478
|
+
const textColor = settings?.textColor || "#344054";
|
|
479
|
+
const bgColor = settings?.backgroundColor || "#ffffff";
|
|
480
|
+
const radius = settings?.borderRadius || "8px";
|
|
481
|
+
const font = settings?.font || "inherit";
|
|
482
|
+
|
|
393
483
|
const inputStyle = {
|
|
394
484
|
width: "100%",
|
|
395
485
|
padding: "10px 12px",
|
|
396
|
-
border: `1px solid ${
|
|
397
|
-
borderRadius:
|
|
486
|
+
border: `1px solid ${borderColor}`,
|
|
487
|
+
borderRadius: radius,
|
|
398
488
|
fontSize: "13px",
|
|
399
|
-
fontFamily:
|
|
400
|
-
color:
|
|
401
|
-
backgroundColor:
|
|
402
|
-
marginBottom: "
|
|
489
|
+
fontFamily: font,
|
|
490
|
+
color: textColor,
|
|
491
|
+
backgroundColor: bgColor,
|
|
492
|
+
marginBottom: "2px",
|
|
403
493
|
boxSizing: "border-box" as const,
|
|
494
|
+
outline: "none",
|
|
404
495
|
};
|
|
405
496
|
|
|
406
497
|
const errorStyle = {
|
|
407
498
|
color: "#DC2626",
|
|
408
|
-
fontSize: "
|
|
499
|
+
fontSize: "11px",
|
|
409
500
|
marginTop: "4px",
|
|
410
|
-
marginBottom: "
|
|
501
|
+
marginBottom: "0px",
|
|
411
502
|
};
|
|
412
503
|
|
|
413
504
|
const labelStyle = {
|
|
414
505
|
...componentTextStyle,
|
|
415
506
|
fontSize: "13px",
|
|
416
|
-
fontWeight: "500",
|
|
417
|
-
marginBottom: "
|
|
507
|
+
fontWeight: "500" as const,
|
|
508
|
+
marginBottom: "4px",
|
|
418
509
|
display: "block",
|
|
419
510
|
};
|
|
420
511
|
|
|
512
|
+
const hintStyle = {
|
|
513
|
+
fontSize: "11px",
|
|
514
|
+
color: "#667085",
|
|
515
|
+
marginTop: "2px",
|
|
516
|
+
marginBottom: "0px",
|
|
517
|
+
lineHeight: "1.4",
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const infoBannerStyle = {
|
|
521
|
+
display: "flex",
|
|
522
|
+
alignItems: "flex-start",
|
|
523
|
+
gap: "8px",
|
|
524
|
+
padding: "10px 12px",
|
|
525
|
+
backgroundColor: "#EFF6FF",
|
|
526
|
+
border: "1px solid #BFDBFE",
|
|
527
|
+
borderRadius: radius,
|
|
528
|
+
marginBottom: "16px",
|
|
529
|
+
fontSize: "12px",
|
|
530
|
+
lineHeight: "1.5",
|
|
531
|
+
color: "#1E40AF",
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const selectedLabel = RELATIONSHIP_OPTIONS.find((o) => o.value === formData.guardianRelationship)?.label;
|
|
535
|
+
const hasError = Boolean(errors.guardianRelationship);
|
|
536
|
+
|
|
421
537
|
return (
|
|
422
538
|
<>
|
|
423
539
|
<div style={{ ...styles.topSection, ...componentTextStyle }}>
|
|
424
540
|
<div style={styles.topLeft}>
|
|
425
|
-
<img style={styles.logo} src={logoUrl} alt="
|
|
541
|
+
<img style={styles.logo} src={logoUrl} alt="Logo" />
|
|
426
542
|
<h2
|
|
427
543
|
id="guardian-form-title"
|
|
428
544
|
style={{
|
|
@@ -433,7 +549,7 @@ const GuardianForm: React.FC<{
|
|
|
433
549
|
fontWeight: 600,
|
|
434
550
|
}}
|
|
435
551
|
>
|
|
436
|
-
Guardian
|
|
552
|
+
Guardian Verification
|
|
437
553
|
</h2>
|
|
438
554
|
</div>
|
|
439
555
|
</div>
|
|
@@ -450,28 +566,46 @@ const GuardianForm: React.FC<{
|
|
|
450
566
|
...styles.privacyText,
|
|
451
567
|
...responsiveStyles.privacyText,
|
|
452
568
|
...componentTextStyle,
|
|
453
|
-
fontSize: "
|
|
454
|
-
lineHeight: "1.
|
|
569
|
+
fontSize: "13px",
|
|
570
|
+
lineHeight: "1.5",
|
|
571
|
+
marginBottom: "12px",
|
|
455
572
|
}}
|
|
456
573
|
>
|
|
457
|
-
|
|
458
|
-
|
|
574
|
+
Since you are under the age of consent, a legal guardian must verify
|
|
575
|
+
their identity via DigiLocker before you can proceed.
|
|
459
576
|
</p>
|
|
460
577
|
|
|
578
|
+
<div style={infoBannerStyle}>
|
|
579
|
+
<svg
|
|
580
|
+
width="16"
|
|
581
|
+
height="16"
|
|
582
|
+
viewBox="0 0 24 24"
|
|
583
|
+
fill="none"
|
|
584
|
+
stroke="#2563EB"
|
|
585
|
+
strokeWidth="2"
|
|
586
|
+
strokeLinecap="round"
|
|
587
|
+
strokeLinejoin="round"
|
|
588
|
+
style={{ flexShrink: 0, marginTop: "1px" }}
|
|
589
|
+
>
|
|
590
|
+
<circle cx="12" cy="12" r="10" />
|
|
591
|
+
<line x1="12" y1="16" x2="12" y2="12" />
|
|
592
|
+
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
593
|
+
</svg>
|
|
594
|
+
<span>
|
|
595
|
+
The guardian's name will be matched against their DigiLocker identity document (e.g. Aadhaar). Please enter the name exactly as it appears on the document.
|
|
596
|
+
</span>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
461
599
|
<div
|
|
462
600
|
style={{
|
|
463
601
|
display: "flex",
|
|
464
602
|
flexDirection: "column",
|
|
465
|
-
gap: "
|
|
466
|
-
marginTop: "16px",
|
|
603
|
+
gap: "14px",
|
|
467
604
|
}}
|
|
468
605
|
>
|
|
469
606
|
<div>
|
|
470
|
-
<label
|
|
471
|
-
|
|
472
|
-
style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
|
|
473
|
-
>
|
|
474
|
-
Name of Guardian *
|
|
607
|
+
<label htmlFor="guardianName" style={labelStyle}>
|
|
608
|
+
Guardian's Full Name <span style={{ color: "#DC2626" }}>*</span>
|
|
475
609
|
</label>
|
|
476
610
|
<input
|
|
477
611
|
id="guardianName"
|
|
@@ -480,64 +614,171 @@ const GuardianForm: React.FC<{
|
|
|
480
614
|
onChange={(e) => onChange("guardianName", e.target.value)}
|
|
481
615
|
style={{
|
|
482
616
|
...inputStyle,
|
|
483
|
-
borderColor: errors.guardianName
|
|
484
|
-
? "#DC2626"
|
|
485
|
-
: inputStyle.border,
|
|
617
|
+
borderColor: errors.guardianName ? "#DC2626" : borderColor,
|
|
486
618
|
}}
|
|
487
|
-
placeholder="
|
|
619
|
+
placeholder="e.g. Rajesh Kumar"
|
|
620
|
+
autoComplete="name"
|
|
621
|
+
aria-describedby="guardianName-hint"
|
|
488
622
|
/>
|
|
623
|
+
<p id="guardianName-hint" style={hintStyle}>
|
|
624
|
+
Enter the full name as per government ID (Aadhaar, PAN, etc.)
|
|
625
|
+
</p>
|
|
489
626
|
{errors.guardianName && (
|
|
490
|
-
<div style={errorStyle}>{errors.guardianName}</div>
|
|
627
|
+
<div style={errorStyle} role="alert">{errors.guardianName}</div>
|
|
491
628
|
)}
|
|
492
629
|
</div>
|
|
493
630
|
|
|
494
631
|
<div>
|
|
495
|
-
<label
|
|
496
|
-
|
|
497
|
-
style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
|
|
498
|
-
>
|
|
499
|
-
Contact of Guardian *
|
|
632
|
+
<label htmlFor="guardianContact" style={labelStyle}>
|
|
633
|
+
Guardian's Phone Number <span style={{ color: "#DC2626" }}>*</span>
|
|
500
634
|
</label>
|
|
501
635
|
<input
|
|
502
636
|
id="guardianContact"
|
|
503
|
-
type="
|
|
637
|
+
type="tel"
|
|
638
|
+
inputMode="tel"
|
|
504
639
|
value={formData.guardianContact}
|
|
505
640
|
onChange={(e) => onChange("guardianContact", e.target.value)}
|
|
506
641
|
style={{
|
|
507
642
|
...inputStyle,
|
|
508
|
-
borderColor: errors.guardianContact
|
|
509
|
-
? "#DC2626"
|
|
510
|
-
: inputStyle.border,
|
|
643
|
+
borderColor: errors.guardianContact ? "#DC2626" : borderColor,
|
|
511
644
|
}}
|
|
512
|
-
placeholder="
|
|
645
|
+
placeholder="e.g. 9876543210"
|
|
646
|
+
autoComplete="tel"
|
|
647
|
+
aria-describedby="guardianContact-hint"
|
|
513
648
|
/>
|
|
649
|
+
<p id="guardianContact-hint" style={hintStyle}>
|
|
650
|
+
Mobile number linked to guardian's Aadhaar
|
|
651
|
+
</p>
|
|
514
652
|
{errors.guardianContact && (
|
|
515
|
-
<div style={errorStyle}>{errors.guardianContact}</div>
|
|
653
|
+
<div style={errorStyle} role="alert">{errors.guardianContact}</div>
|
|
516
654
|
)}
|
|
517
655
|
</div>
|
|
518
656
|
|
|
519
|
-
<div>
|
|
520
|
-
<label
|
|
521
|
-
|
|
522
|
-
style={{ ...labelStyle, marginBottom: "8px", display: "block" }}
|
|
523
|
-
>
|
|
524
|
-
Relationship to Guardian *
|
|
657
|
+
<div ref={dropdownRef} style={{ position: "relative" }}>
|
|
658
|
+
<label id="guardianRelationship-label" style={labelStyle}>
|
|
659
|
+
Relationship <span style={{ color: "#DC2626" }}>*</span>
|
|
525
660
|
</label>
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
661
|
+
<div
|
|
662
|
+
role="combobox"
|
|
663
|
+
aria-expanded={isDropdownOpen}
|
|
664
|
+
aria-haspopup="listbox"
|
|
665
|
+
aria-labelledby="guardianRelationship-label"
|
|
666
|
+
aria-controls="guardianRelationship-listbox"
|
|
667
|
+
aria-activedescendant={focusedIndex >= 0 ? `guardianRelationship-option-${focusedIndex}` : undefined}
|
|
668
|
+
tabIndex={0}
|
|
669
|
+
onClick={() => { setIsDropdownOpen((prev) => !prev); setFocusedIndex(-1); }}
|
|
670
|
+
onKeyDown={handleKeyDown}
|
|
531
671
|
style={{
|
|
532
672
|
...inputStyle,
|
|
533
|
-
borderColor:
|
|
534
|
-
|
|
535
|
-
|
|
673
|
+
borderColor: hasError ? "#DC2626" : isDropdownOpen ? (settings?.button?.accept?.backgroundColor || "#3b82f6") : borderColor,
|
|
674
|
+
boxShadow: isDropdownOpen ? `0 0 0 1px ${settings?.button?.accept?.backgroundColor || "#3b82f6"}` : "none",
|
|
675
|
+
color: selectedLabel ? textColor : "#9CA3AF",
|
|
676
|
+
cursor: "pointer",
|
|
677
|
+
display: "flex",
|
|
678
|
+
alignItems: "center",
|
|
679
|
+
justifyContent: "space-between",
|
|
680
|
+
userSelect: "none",
|
|
681
|
+
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
536
682
|
}}
|
|
537
|
-
|
|
538
|
-
|
|
683
|
+
>
|
|
684
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
685
|
+
{selectedLabel || "Select relationship"}
|
|
686
|
+
</span>
|
|
687
|
+
<svg
|
|
688
|
+
width="14"
|
|
689
|
+
height="14"
|
|
690
|
+
viewBox="0 0 24 24"
|
|
691
|
+
fill="none"
|
|
692
|
+
stroke="#667085"
|
|
693
|
+
strokeWidth="2"
|
|
694
|
+
strokeLinecap="round"
|
|
695
|
+
strokeLinejoin="round"
|
|
696
|
+
style={{
|
|
697
|
+
flexShrink: 0,
|
|
698
|
+
marginLeft: "8px",
|
|
699
|
+
transform: isDropdownOpen ? "rotate(180deg)" : "rotate(0deg)",
|
|
700
|
+
transition: "transform 0.2s",
|
|
701
|
+
}}
|
|
702
|
+
>
|
|
703
|
+
<polyline points="6 9 12 15 18 9" />
|
|
704
|
+
</svg>
|
|
705
|
+
</div>
|
|
706
|
+
|
|
707
|
+
{isDropdownOpen && (
|
|
708
|
+
<div
|
|
709
|
+
ref={optionsRef}
|
|
710
|
+
id="guardianRelationship-listbox"
|
|
711
|
+
role="listbox"
|
|
712
|
+
aria-labelledby="guardianRelationship-label"
|
|
713
|
+
style={{
|
|
714
|
+
position: "absolute",
|
|
715
|
+
...(openUpward
|
|
716
|
+
? { bottom: "calc(100% + 4px)" }
|
|
717
|
+
: { top: "calc(100% + 4px)" }),
|
|
718
|
+
left: 0,
|
|
719
|
+
right: 0,
|
|
720
|
+
backgroundColor: bgColor,
|
|
721
|
+
border: `1px solid ${borderColor}`,
|
|
722
|
+
borderRadius: radius,
|
|
723
|
+
boxShadow: openUpward
|
|
724
|
+
? "0 -4px 12px rgba(0, 0, 0, 0.1), 0 -1px 3px rgba(0, 0, 0, 0.06)"
|
|
725
|
+
: "0 4px 12px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06)",
|
|
726
|
+
zIndex: 50,
|
|
727
|
+
overflow: "hidden",
|
|
728
|
+
padding: "4px 0",
|
|
729
|
+
maxHeight: "220px",
|
|
730
|
+
overflowY: "auto",
|
|
731
|
+
}}
|
|
732
|
+
>
|
|
733
|
+
{RELATIONSHIP_OPTIONS.map((option, index) => {
|
|
734
|
+
const isSelected = formData.guardianRelationship === option.value;
|
|
735
|
+
const isFocused = focusedIndex === index;
|
|
736
|
+
const accentColor = settings?.button?.accept?.backgroundColor || "#3b82f6";
|
|
737
|
+
return (
|
|
738
|
+
<div
|
|
739
|
+
key={option.value}
|
|
740
|
+
id={`guardianRelationship-option-${index}`}
|
|
741
|
+
role="option"
|
|
742
|
+
aria-selected={isSelected}
|
|
743
|
+
onClick={() => handleSelect(option.value)}
|
|
744
|
+
onMouseEnter={() => setFocusedIndex(index)}
|
|
745
|
+
style={{
|
|
746
|
+
padding: "9px 12px",
|
|
747
|
+
fontSize: "13px",
|
|
748
|
+
fontFamily: font,
|
|
749
|
+
color: isSelected ? accentColor : textColor,
|
|
750
|
+
backgroundColor: isFocused ? "#F3F4F6" : "transparent",
|
|
751
|
+
cursor: "pointer",
|
|
752
|
+
display: "flex",
|
|
753
|
+
alignItems: "center",
|
|
754
|
+
justifyContent: "space-between",
|
|
755
|
+
transition: "background-color 0.1s",
|
|
756
|
+
}}
|
|
757
|
+
>
|
|
758
|
+
<span>{option.label}</span>
|
|
759
|
+
{isSelected && (
|
|
760
|
+
<svg
|
|
761
|
+
width="14"
|
|
762
|
+
height="14"
|
|
763
|
+
viewBox="0 0 24 24"
|
|
764
|
+
fill="none"
|
|
765
|
+
stroke={accentColor}
|
|
766
|
+
strokeWidth="2.5"
|
|
767
|
+
strokeLinecap="round"
|
|
768
|
+
strokeLinejoin="round"
|
|
769
|
+
style={{ flexShrink: 0 }}
|
|
770
|
+
>
|
|
771
|
+
<polyline points="20 6 9 17 4 12" />
|
|
772
|
+
</svg>
|
|
773
|
+
)}
|
|
774
|
+
</div>
|
|
775
|
+
);
|
|
776
|
+
})}
|
|
777
|
+
</div>
|
|
778
|
+
)}
|
|
779
|
+
|
|
539
780
|
{errors.guardianRelationship && (
|
|
540
|
-
<div style={errorStyle}>{errors.guardianRelationship}</div>
|
|
781
|
+
<div style={errorStyle} role="alert">{errors.guardianRelationship}</div>
|
|
541
782
|
)}
|
|
542
783
|
</div>
|
|
543
784
|
</div>
|
|
@@ -547,7 +788,7 @@ const GuardianForm: React.FC<{
|
|
|
547
788
|
role="alert"
|
|
548
789
|
style={{
|
|
549
790
|
color: "#DC2626",
|
|
550
|
-
fontSize: "
|
|
791
|
+
fontSize: "13px",
|
|
551
792
|
marginTop: "16px",
|
|
552
793
|
padding: "12px",
|
|
553
794
|
borderRadius: "6px",
|
|
@@ -581,11 +822,11 @@ const GuardianForm: React.FC<{
|
|
|
581
822
|
type="button"
|
|
582
823
|
aria-label={
|
|
583
824
|
isSubmitting
|
|
584
|
-
? "
|
|
585
|
-
: "
|
|
825
|
+
? "Verifying guardian identity"
|
|
826
|
+
: "Proceed to guardian verification"
|
|
586
827
|
}
|
|
587
828
|
>
|
|
588
|
-
{isSubmitting ? "
|
|
829
|
+
{isSubmitting ? "Verifying..." : "Verify via DigiLocker"}
|
|
589
830
|
</button>
|
|
590
831
|
<button
|
|
591
832
|
style={{
|
|
@@ -596,7 +837,7 @@ const GuardianForm: React.FC<{
|
|
|
596
837
|
}}
|
|
597
838
|
onClick={onDecline}
|
|
598
839
|
type="button"
|
|
599
|
-
aria-label="Cancel
|
|
840
|
+
aria-label="Cancel"
|
|
600
841
|
>
|
|
601
842
|
Cancel
|
|
602
843
|
</button>
|
|
@@ -605,6 +846,292 @@ const GuardianForm: React.FC<{
|
|
|
605
846
|
);
|
|
606
847
|
};
|
|
607
848
|
|
|
849
|
+
/**
|
|
850
|
+
* VerificationScreen Component
|
|
851
|
+
*
|
|
852
|
+
* Displays verification status while DigiLocker verification is in progress.
|
|
853
|
+
* No longer requires document type selection - handled by backend.
|
|
854
|
+
*/
|
|
855
|
+
const VerificationScreen: React.FC<{
|
|
856
|
+
onBack: (clearName?: boolean) => void;
|
|
857
|
+
onContinue: () => void;
|
|
858
|
+
isInitiating: boolean;
|
|
859
|
+
isPolling: boolean;
|
|
860
|
+
isVerificationComplete: boolean;
|
|
861
|
+
isAutoTransitioning: boolean;
|
|
862
|
+
error: string | null;
|
|
863
|
+
errorCode: string | null;
|
|
864
|
+
canRetry: boolean;
|
|
865
|
+
styles: any;
|
|
866
|
+
responsiveStyles: any;
|
|
867
|
+
settings?: any;
|
|
868
|
+
componentHeadingStyle: any;
|
|
869
|
+
componentTextStyle: any;
|
|
870
|
+
acceptButtonStyle: any;
|
|
871
|
+
declineButtonStyle: any;
|
|
872
|
+
logoUrl: string;
|
|
873
|
+
}> = ({
|
|
874
|
+
onBack,
|
|
875
|
+
onContinue,
|
|
876
|
+
isInitiating,
|
|
877
|
+
isPolling,
|
|
878
|
+
isVerificationComplete,
|
|
879
|
+
isAutoTransitioning,
|
|
880
|
+
error,
|
|
881
|
+
errorCode,
|
|
882
|
+
canRetry,
|
|
883
|
+
styles,
|
|
884
|
+
responsiveStyles,
|
|
885
|
+
settings,
|
|
886
|
+
componentHeadingStyle,
|
|
887
|
+
componentTextStyle,
|
|
888
|
+
acceptButtonStyle,
|
|
889
|
+
declineButtonStyle,
|
|
890
|
+
logoUrl,
|
|
891
|
+
}) => {
|
|
892
|
+
const errorStyle = {
|
|
893
|
+
color: "#DC2626",
|
|
894
|
+
fontSize: "14px",
|
|
895
|
+
marginTop: "16px",
|
|
896
|
+
padding: "12px",
|
|
897
|
+
borderRadius: "6px",
|
|
898
|
+
border: "1px solid #FCA5A5",
|
|
899
|
+
backgroundColor: "#FEF2F2",
|
|
900
|
+
textAlign: "center" as const,
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const statusStyle = {
|
|
904
|
+
color: "#059669",
|
|
905
|
+
fontSize: "14px",
|
|
906
|
+
marginTop: "16px",
|
|
907
|
+
padding: "12px",
|
|
908
|
+
borderRadius: "6px",
|
|
909
|
+
border: "1px solid #A7F3D0",
|
|
910
|
+
backgroundColor: "#D1FAE5",
|
|
911
|
+
textAlign: "center" as const,
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const spinnerStyle = {
|
|
915
|
+
width: "48px",
|
|
916
|
+
height: "48px",
|
|
917
|
+
border: "4px solid #e5e7eb",
|
|
918
|
+
borderTopColor: settings?.button?.accept?.backgroundColor || "#3b82f6",
|
|
919
|
+
borderRadius: "50%",
|
|
920
|
+
animation: "spin 1s linear infinite",
|
|
921
|
+
margin: "24px auto",
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
return (
|
|
925
|
+
<>
|
|
926
|
+
<div style={{ ...styles.topSection, ...componentTextStyle }}>
|
|
927
|
+
<div style={styles.topLeft}>
|
|
928
|
+
<img style={styles.logo} src={logoUrl} alt="Logo" />
|
|
929
|
+
<h2
|
|
930
|
+
id="verification-title"
|
|
931
|
+
style={{
|
|
932
|
+
...styles.title,
|
|
933
|
+
...responsiveStyles.title,
|
|
934
|
+
...componentHeadingStyle,
|
|
935
|
+
fontSize: "16px",
|
|
936
|
+
fontWeight: 600,
|
|
937
|
+
}}
|
|
938
|
+
>
|
|
939
|
+
Guardian Verification
|
|
940
|
+
</h2>
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
|
|
944
|
+
<div
|
|
945
|
+
style={{
|
|
946
|
+
...styles.middleSection,
|
|
947
|
+
...responsiveStyles.middleSection,
|
|
948
|
+
...componentTextStyle,
|
|
949
|
+
textAlign: "center" as const,
|
|
950
|
+
}}
|
|
951
|
+
>
|
|
952
|
+
{isVerificationComplete && !error ? (
|
|
953
|
+
<div
|
|
954
|
+
style={{
|
|
955
|
+
display: "flex",
|
|
956
|
+
flexDirection: "column",
|
|
957
|
+
alignItems: "center",
|
|
958
|
+
justifyContent: "center",
|
|
959
|
+
flex: 1,
|
|
960
|
+
}}
|
|
961
|
+
>
|
|
962
|
+
<div
|
|
963
|
+
style={{
|
|
964
|
+
width: "64px",
|
|
965
|
+
height: "64px",
|
|
966
|
+
borderRadius: "50%",
|
|
967
|
+
backgroundColor: "#D1FAE5",
|
|
968
|
+
display: "flex",
|
|
969
|
+
alignItems: "center",
|
|
970
|
+
justifyContent: "center",
|
|
971
|
+
}}
|
|
972
|
+
>
|
|
973
|
+
<svg
|
|
974
|
+
width="32"
|
|
975
|
+
height="32"
|
|
976
|
+
viewBox="0 0 24 24"
|
|
977
|
+
fill="none"
|
|
978
|
+
stroke="#059669"
|
|
979
|
+
strokeWidth="2"
|
|
980
|
+
strokeLinecap="round"
|
|
981
|
+
strokeLinejoin="round"
|
|
982
|
+
>
|
|
983
|
+
<polyline points="20 6 9 17 4 12" />
|
|
984
|
+
</svg>
|
|
985
|
+
</div>
|
|
986
|
+
<p
|
|
987
|
+
style={{
|
|
988
|
+
...componentHeadingStyle,
|
|
989
|
+
fontSize: "16px",
|
|
990
|
+
fontWeight: 600,
|
|
991
|
+
lineHeight: "1.5",
|
|
992
|
+
marginTop: "12px",
|
|
993
|
+
marginBottom: "24px",
|
|
994
|
+
}}
|
|
995
|
+
>
|
|
996
|
+
Verification Completed
|
|
997
|
+
</p>
|
|
998
|
+
</div>
|
|
999
|
+
) : (isInitiating || isPolling) && !error ? (
|
|
1000
|
+
<>
|
|
1001
|
+
<div style={spinnerStyle} />
|
|
1002
|
+
<div style={statusStyle}>
|
|
1003
|
+
{isInitiating
|
|
1004
|
+
? "Opening DigiLocker in a new tab..."
|
|
1005
|
+
: "Please complete the verification in the DigiLocker tab. This page will update automatically when verification is complete."}
|
|
1006
|
+
</div>
|
|
1007
|
+
</>
|
|
1008
|
+
) : null}
|
|
1009
|
+
|
|
1010
|
+
{error && (
|
|
1011
|
+
<>
|
|
1012
|
+
<div
|
|
1013
|
+
style={{
|
|
1014
|
+
width: "64px",
|
|
1015
|
+
height: "64px",
|
|
1016
|
+
borderRadius: "50%",
|
|
1017
|
+
backgroundColor: "#fee2e2",
|
|
1018
|
+
display: "flex",
|
|
1019
|
+
alignItems: "center",
|
|
1020
|
+
justifyContent: "center",
|
|
1021
|
+
margin: "24px auto",
|
|
1022
|
+
}}
|
|
1023
|
+
>
|
|
1024
|
+
<svg
|
|
1025
|
+
width="32"
|
|
1026
|
+
height="32"
|
|
1027
|
+
viewBox="0 0 24 24"
|
|
1028
|
+
fill="none"
|
|
1029
|
+
stroke="#dc2626"
|
|
1030
|
+
strokeWidth="2"
|
|
1031
|
+
strokeLinecap="round"
|
|
1032
|
+
strokeLinejoin="round"
|
|
1033
|
+
>
|
|
1034
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
1035
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
1036
|
+
</svg>
|
|
1037
|
+
</div>
|
|
1038
|
+
<p
|
|
1039
|
+
style={{
|
|
1040
|
+
...styles.privacyText,
|
|
1041
|
+
...responsiveStyles.privacyText,
|
|
1042
|
+
...componentTextStyle,
|
|
1043
|
+
fontSize: "14px",
|
|
1044
|
+
lineHeight: "1.5",
|
|
1045
|
+
marginBottom: "12px",
|
|
1046
|
+
}}
|
|
1047
|
+
>
|
|
1048
|
+
Verification failed
|
|
1049
|
+
</p>
|
|
1050
|
+
<div style={errorStyle}>{error}</div>
|
|
1051
|
+
</>
|
|
1052
|
+
)}
|
|
1053
|
+
</div>
|
|
1054
|
+
|
|
1055
|
+
{isAutoTransitioning ? null : (
|
|
1056
|
+
<div
|
|
1057
|
+
style={{
|
|
1058
|
+
...styles.bottomSection,
|
|
1059
|
+
...responsiveStyles.bottomSection,
|
|
1060
|
+
...componentTextStyle,
|
|
1061
|
+
paddingTop: "12px",
|
|
1062
|
+
display: "flex",
|
|
1063
|
+
gap: "12px",
|
|
1064
|
+
justifyContent: "center",
|
|
1065
|
+
}}
|
|
1066
|
+
>
|
|
1067
|
+
{isVerificationComplete && !error ? (
|
|
1068
|
+
<button
|
|
1069
|
+
style={{
|
|
1070
|
+
...styles.button,
|
|
1071
|
+
...responsiveStyles.button,
|
|
1072
|
+
...acceptButtonStyle,
|
|
1073
|
+
}}
|
|
1074
|
+
onClick={onContinue}
|
|
1075
|
+
type="button"
|
|
1076
|
+
aria-label="Continue to consent form"
|
|
1077
|
+
>
|
|
1078
|
+
Continue
|
|
1079
|
+
</button>
|
|
1080
|
+
) : error && canRetry ? (
|
|
1081
|
+
<button
|
|
1082
|
+
style={{
|
|
1083
|
+
...styles.button,
|
|
1084
|
+
...responsiveStyles.button,
|
|
1085
|
+
...styles.acceptButton,
|
|
1086
|
+
...acceptButtonStyle,
|
|
1087
|
+
}}
|
|
1088
|
+
onClick={() => onBack(errorCode === "GUARDIAN_UNDER_18")}
|
|
1089
|
+
type="button"
|
|
1090
|
+
aria-label={
|
|
1091
|
+
errorCode === "GUARDIAN_UNDER_18"
|
|
1092
|
+
? "Change guardian details"
|
|
1093
|
+
: "Try verification again"
|
|
1094
|
+
}
|
|
1095
|
+
>
|
|
1096
|
+
{errorCode === "GUARDIAN_UNDER_18"
|
|
1097
|
+
? "Change Guardian"
|
|
1098
|
+
: "Try Again"}
|
|
1099
|
+
</button>
|
|
1100
|
+
) : error ? (
|
|
1101
|
+
<button
|
|
1102
|
+
style={{
|
|
1103
|
+
...styles.button,
|
|
1104
|
+
...responsiveStyles.button,
|
|
1105
|
+
...styles.cancelButton,
|
|
1106
|
+
...declineButtonStyle,
|
|
1107
|
+
}}
|
|
1108
|
+
onClick={() => onBack(false)}
|
|
1109
|
+
type="button"
|
|
1110
|
+
aria-label="Go back"
|
|
1111
|
+
>
|
|
1112
|
+
Go Back
|
|
1113
|
+
</button>
|
|
1114
|
+
) : (
|
|
1115
|
+
<button
|
|
1116
|
+
style={{
|
|
1117
|
+
...styles.button,
|
|
1118
|
+
...responsiveStyles.button,
|
|
1119
|
+
...styles.cancelButton,
|
|
1120
|
+
...declineButtonStyle,
|
|
1121
|
+
}}
|
|
1122
|
+
onClick={() => onBack(false)}
|
|
1123
|
+
type="button"
|
|
1124
|
+
aria-label="Cancel verification"
|
|
1125
|
+
>
|
|
1126
|
+
Cancel
|
|
1127
|
+
</button>
|
|
1128
|
+
)}
|
|
1129
|
+
</div>
|
|
1130
|
+
)}
|
|
1131
|
+
</>
|
|
1132
|
+
);
|
|
1133
|
+
};
|
|
1134
|
+
|
|
608
1135
|
/**
|
|
609
1136
|
* AgeVerification Component
|
|
610
1137
|
*
|
|
@@ -776,6 +1303,8 @@ export const RedactoNoticeConsent = ({
|
|
|
776
1303
|
settings,
|
|
777
1304
|
applicationId,
|
|
778
1305
|
validateAgainst = "all",
|
|
1306
|
+
digilockerMode = "redirect", // Default to full page redirect (DigiLocker blocks iframe embedding)
|
|
1307
|
+
digilockerCallbackUrl = "/digilocker-callback", // Default callback path
|
|
779
1308
|
includeFullyConsentedData = false,
|
|
780
1309
|
reviewModeButtonText = "Continue",
|
|
781
1310
|
}: Props) => {
|
|
@@ -869,6 +1398,8 @@ export const RedactoNoticeConsent = ({
|
|
|
869
1398
|
|
|
870
1399
|
// Age verification states
|
|
871
1400
|
const [showAgeVerification, setShowAgeVerification] = useState(false);
|
|
1401
|
+
const [isMinorFlow, setIsMinorFlow] = useState(false);
|
|
1402
|
+
const [selfDeclaredAdult, setSelfDeclaredAdult] = useState(false);
|
|
872
1403
|
|
|
873
1404
|
// Guardian form states
|
|
874
1405
|
const [showGuardianForm, setShowGuardianForm] = useState(false);
|
|
@@ -882,13 +1413,36 @@ export const RedactoNoticeConsent = ({
|
|
|
882
1413
|
Record<string, string>
|
|
883
1414
|
>({});
|
|
884
1415
|
|
|
1416
|
+
// Guardian verification states
|
|
1417
|
+
const [showVerificationScreen, setShowVerificationScreen] = useState(false);
|
|
1418
|
+
const [verificationReference, setVerificationReference] = useState<string | null>(null);
|
|
1419
|
+
const [isInitiatingVerification, setIsInitiatingVerification] =
|
|
1420
|
+
useState(false);
|
|
1421
|
+
const [verificationError, setVerificationError] = useState<string | null>(
|
|
1422
|
+
null
|
|
1423
|
+
);
|
|
1424
|
+
const [verificationErrorCode, setVerificationErrorCode] = useState<string | null>(null);
|
|
1425
|
+
const [canRetryVerification, setCanRetryVerification] = useState(false);
|
|
1426
|
+
const [isPollingStatus, setIsPollingStatus] = useState(false);
|
|
1427
|
+
const [isVerificationComplete, setIsVerificationComplete] = useState(false);
|
|
1428
|
+
const [isAutoTransitioning, setIsAutoTransitioning] = useState(false);
|
|
1429
|
+
|
|
885
1430
|
// Refs and constants
|
|
886
1431
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
887
1432
|
const firstFocusableRef = useRef<HTMLButtonElement>(null);
|
|
888
1433
|
const lastFocusableRef = useRef<HTMLButtonElement>(null);
|
|
889
1434
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
890
|
-
const isRequestInProgressRef = useRef(false);
|
|
1435
|
+
const isRequestInProgressRef = useRef(false);
|
|
891
1436
|
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
1437
|
+
const popupCheckIntervalRef = useRef<number | null>(null);
|
|
1438
|
+
const statusPollIntervalRef = useRef<number | null>(null);
|
|
1439
|
+
const verificationPopupRef = useRef<Window | null>(null);
|
|
1440
|
+
const pollingCleanupRef = useRef<(() => void) | null>(null);
|
|
1441
|
+
const autoTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1442
|
+
const digilockerRedirectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1443
|
+
const isInitialMountRef = useRef(true);
|
|
1444
|
+
const isPollingStatusRef = useRef(false);
|
|
1445
|
+
const verificationSessionTokenRef = useRef<string | null>(null);
|
|
892
1446
|
|
|
893
1447
|
// =============================================================================
|
|
894
1448
|
// RESPONSIVE DESIGN & STYLING
|
|
@@ -1049,6 +1603,8 @@ export const RedactoNoticeConsent = ({
|
|
|
1049
1603
|
|
|
1050
1604
|
// Clear age verification and guardian form state
|
|
1051
1605
|
setShowAgeVerification(false);
|
|
1606
|
+
setIsMinorFlow(false);
|
|
1607
|
+
setSelfDeclaredAdult(false);
|
|
1052
1608
|
setShowGuardianForm(false);
|
|
1053
1609
|
setIsSubmittingGuardian(false);
|
|
1054
1610
|
setGuardianFormData({
|
|
@@ -1057,6 +1613,47 @@ export const RedactoNoticeConsent = ({
|
|
|
1057
1613
|
guardianRelationship: "",
|
|
1058
1614
|
});
|
|
1059
1615
|
setGuardianFormErrors({});
|
|
1616
|
+
|
|
1617
|
+
// Clear verification state
|
|
1618
|
+
setShowVerificationScreen(false);
|
|
1619
|
+
setVerificationReference(null);
|
|
1620
|
+
setIsInitiatingVerification(false);
|
|
1621
|
+
setVerificationError(null);
|
|
1622
|
+
setVerificationErrorCode(null);
|
|
1623
|
+
setCanRetryVerification(false);
|
|
1624
|
+
setIsVerificationComplete(false);
|
|
1625
|
+
setIsAutoTransitioning(false);
|
|
1626
|
+
setIsPollingStatus(false);
|
|
1627
|
+
isPollingStatusRef.current = false;
|
|
1628
|
+
verificationSessionTokenRef.current = null;
|
|
1629
|
+
if (popupCheckIntervalRef.current) {
|
|
1630
|
+
clearInterval(popupCheckIntervalRef.current);
|
|
1631
|
+
popupCheckIntervalRef.current = null;
|
|
1632
|
+
}
|
|
1633
|
+
if (statusPollIntervalRef.current) {
|
|
1634
|
+
clearTimeout(statusPollIntervalRef.current);
|
|
1635
|
+
statusPollIntervalRef.current = null;
|
|
1636
|
+
}
|
|
1637
|
+
// Clean up visibilitychange listener if polling was active
|
|
1638
|
+
pollingCleanupRef.current?.();
|
|
1639
|
+
pollingCleanupRef.current = null;
|
|
1640
|
+
verificationPopupRef.current?.close();
|
|
1641
|
+
verificationPopupRef.current = null;
|
|
1642
|
+
if (autoTransitionTimerRef.current) {
|
|
1643
|
+
clearTimeout(autoTransitionTimerRef.current);
|
|
1644
|
+
autoTransitionTimerRef.current = null;
|
|
1645
|
+
}
|
|
1646
|
+
if (digilockerRedirectTimeoutRef.current) {
|
|
1647
|
+
clearTimeout(digilockerRedirectTimeoutRef.current);
|
|
1648
|
+
digilockerRedirectTimeoutRef.current = null;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Clear persisted state only on token change, not initial mount.
|
|
1652
|
+
// The restore effect (below) needs to read persisted state on first mount.
|
|
1653
|
+
if (!isInitialMountRef.current) {
|
|
1654
|
+
clearConsentState();
|
|
1655
|
+
}
|
|
1656
|
+
isInitialMountRef.current = false;
|
|
1060
1657
|
}, [accessToken]);
|
|
1061
1658
|
|
|
1062
1659
|
/**
|
|
@@ -1361,7 +1958,14 @@ export const RedactoNoticeConsent = ({
|
|
|
1361
1958
|
// Check if age verification is required - set this after all content is loaded
|
|
1362
1959
|
// but before setIsLoading(false) to ensure proper render order
|
|
1363
1960
|
if (consentContentData.detail.is_minor) {
|
|
1364
|
-
|
|
1961
|
+
setIsMinorFlow(true);
|
|
1962
|
+
// Only show age verification if not returning from a DigiLocker redirect
|
|
1963
|
+
// (persisted state means user is mid-verification flow — the state restoration
|
|
1964
|
+
// effect handles showing the correct screen)
|
|
1965
|
+
const persistedState = getConsentState();
|
|
1966
|
+
if (!persistedState?.sessionToken && !persistedState?.verificationReference) {
|
|
1967
|
+
setShowAgeVerification(true);
|
|
1968
|
+
}
|
|
1365
1969
|
}
|
|
1366
1970
|
|
|
1367
1971
|
// Set loading to false after all state updates are complete
|
|
@@ -1494,6 +2098,12 @@ export const RedactoNoticeConsent = ({
|
|
|
1494
2098
|
const submitController = new AbortController();
|
|
1495
2099
|
|
|
1496
2100
|
try {
|
|
2101
|
+
// Block submission if minor consent flow is active but verification reference is missing
|
|
2102
|
+
if (isMinorFlow && !verificationReference) {
|
|
2103
|
+
setErrorMessage("Guardian verification is required before consent can be submitted.");
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
1497
2107
|
setIsSubmittingAccept(true);
|
|
1498
2108
|
setIsSubmitting(true); // Keep for backward compatibility with acceptDisabled
|
|
1499
2109
|
setErrorMessage(null);
|
|
@@ -1544,6 +2154,8 @@ export const RedactoNoticeConsent = ({
|
|
|
1544
2154
|
meta_data: applicationId
|
|
1545
2155
|
? { specific_uuid: applicationId }
|
|
1546
2156
|
: undefined,
|
|
2157
|
+
guardianVerificationReference: verificationReference || undefined,
|
|
2158
|
+
selfDeclaredAdult: selfDeclaredAdult || undefined,
|
|
1547
2159
|
signal: submitController.signal,
|
|
1548
2160
|
});
|
|
1549
2161
|
}
|
|
@@ -1570,33 +2182,11 @@ export const RedactoNoticeConsent = ({
|
|
|
1570
2182
|
const handleDecline = useCallback(async () => {
|
|
1571
2183
|
try {
|
|
1572
2184
|
setIsSubmittingDecline(true);
|
|
1573
|
-
setIsSubmitting(true);
|
|
1574
|
-
// try {
|
|
1575
|
-
// if (content) {
|
|
1576
|
-
// await submitConsentEvent(
|
|
1577
|
-
// accessToken,
|
|
1578
|
-
// content.notice_uuid,
|
|
1579
|
-
// content.purposes.map((purpose) => ({
|
|
1580
|
-
// ...purpose,
|
|
1581
|
-
// selected: false,
|
|
1582
|
-
// data_elements: purpose.data_elements.map((element) => ({
|
|
1583
|
-
// ...element,
|
|
1584
|
-
// selected: element.required ? true : false,
|
|
1585
|
-
// })),
|
|
1586
|
-
// })),
|
|
1587
|
-
// true
|
|
1588
|
-
// );
|
|
1589
|
-
// }
|
|
1590
|
-
// } catch (error) {
|
|
1591
|
-
// console.error("Error submitting consent:", error);
|
|
1592
|
-
// onError?.(error as Error);
|
|
1593
|
-
// } finally {
|
|
1594
|
-
// setIsSubmitting(false);
|
|
1595
|
-
// }
|
|
2185
|
+
setIsSubmitting(true);
|
|
1596
2186
|
onDecline?.();
|
|
1597
2187
|
} finally {
|
|
1598
2188
|
setIsSubmittingDecline(false);
|
|
1599
|
-
setIsSubmitting(false);
|
|
2189
|
+
setIsSubmitting(false);
|
|
1600
2190
|
}
|
|
1601
2191
|
}, [onDecline]);
|
|
1602
2192
|
|
|
@@ -1646,21 +2236,140 @@ export const RedactoNoticeConsent = ({
|
|
|
1646
2236
|
try {
|
|
1647
2237
|
setIsSubmittingGuardian(true);
|
|
1648
2238
|
setGuardianFormErrors({}); // Clear any previous errors
|
|
1649
|
-
|
|
1650
|
-
|
|
2239
|
+
setVerificationError(null);
|
|
2240
|
+
|
|
2241
|
+
// For redirect mode: Don't send frontendCallbackUrl
|
|
2242
|
+
// DigiLocker should redirect to BACKEND callback URL (e.g., http://localhost:8000/consent/public/digilocker/callback)
|
|
2243
|
+
// Backend processes the callback and updates status in database
|
|
2244
|
+
// SDK will poll verify-status endpoint to detect completion
|
|
2245
|
+
//
|
|
2246
|
+
// For popup mode: Send frontendCallbackUrl so backend can redirect back to frontend
|
|
2247
|
+
const callbackUrl = digilockerMode === "popup"
|
|
2248
|
+
? new URL(digilockerCallbackUrl, window.location.origin).href
|
|
2249
|
+
: undefined;
|
|
2250
|
+
|
|
2251
|
+
// Initiate guardian verification - combines guardian details + DigiLocker start
|
|
2252
|
+
const response = await initiateGuardianVerification({
|
|
1651
2253
|
accessToken,
|
|
1652
2254
|
baseUrl,
|
|
1653
2255
|
guardianName: guardianFormData.guardianName,
|
|
1654
2256
|
guardianContact: guardianFormData.guardianContact,
|
|
1655
2257
|
guardianRelationship: guardianFormData.guardianRelationship,
|
|
2258
|
+
frontendCallbackUrl: callbackUrl, // Only for popup mode
|
|
1656
2259
|
signal: guardianController.signal,
|
|
1657
2260
|
});
|
|
1658
2261
|
|
|
1659
|
-
|
|
2262
|
+
setIsSubmittingGuardian(false);
|
|
2263
|
+
|
|
2264
|
+
// ── Already verified: skip DigiLocker, brief success flash then consent form ──
|
|
2265
|
+
if (response.already_verified && response.verification_reference) {
|
|
2266
|
+
setVerificationReference(response.verification_reference);
|
|
2267
|
+
setShowGuardianForm(false);
|
|
2268
|
+
setShowVerificationScreen(true);
|
|
2269
|
+
setIsInitiatingVerification(false);
|
|
2270
|
+
setIsVerificationComplete(true);
|
|
2271
|
+
setIsAutoTransitioning(true);
|
|
2272
|
+
|
|
2273
|
+
// Auto-transition to consent form after brief success flash
|
|
2274
|
+
autoTransitionTimerRef.current = setTimeout(() => {
|
|
2275
|
+
setShowVerificationScreen(false);
|
|
2276
|
+
setIsVerificationComplete(false);
|
|
2277
|
+
setIsAutoTransitioning(false);
|
|
2278
|
+
autoTransitionTimerRef.current = null;
|
|
2279
|
+
}, 1000);
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// ── New verification: needs DigiLocker ──
|
|
2284
|
+
if (!response.session_token || !response.digilocker_redirect_url) {
|
|
2285
|
+
throw new Error("Invalid response from server: missing required fields");
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// Show verification screen before redirect
|
|
1660
2289
|
setShowGuardianForm(false);
|
|
2290
|
+
setShowVerificationScreen(true);
|
|
2291
|
+
setIsInitiatingVerification(true);
|
|
2292
|
+
|
|
2293
|
+
if (digilockerMode === "redirect") {
|
|
2294
|
+
// Save state BEFORE redirect — restored when user returns from DigiLocker callback
|
|
2295
|
+
// NOTE: Guardian PII (name, contact, relationship) intentionally excluded from
|
|
2296
|
+
// persisted state to avoid storing sensitive data in sessionStorage.
|
|
2297
|
+
saveConsentState({
|
|
2298
|
+
flowState: "digilocker-pending",
|
|
2299
|
+
sessionToken: response.session_token,
|
|
2300
|
+
guardianInfoUuid: response.guardian_info_uuid,
|
|
2301
|
+
selectedPurposes,
|
|
2302
|
+
selectedDataElements,
|
|
2303
|
+
timestamp: new Date().toISOString(),
|
|
2304
|
+
originalUrl: window.location.href,
|
|
2305
|
+
noticeId,
|
|
2306
|
+
baseUrl,
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
// Open DigiLocker in a new tab — keeps original page open for polling
|
|
2310
|
+
// Flow: DigiLocker → backend callback → backend updates DB → this tab polls → detects completion
|
|
2311
|
+
// NOTE: Do NOT pass "noopener" in the features string (3rd param) — it causes
|
|
2312
|
+
// window.open to return null per spec, even when the tab opens successfully.
|
|
2313
|
+
// Instead, manually null out opener after opening.
|
|
2314
|
+
try {
|
|
2315
|
+
const openedWindow = window.open(
|
|
2316
|
+
response.digilocker_redirect_url,
|
|
2317
|
+
"_blank"
|
|
2318
|
+
);
|
|
2319
|
+
|
|
2320
|
+
if (!openedWindow) {
|
|
2321
|
+
// Popup was blocked — show error instead of same-tab redirect
|
|
2322
|
+
// (same-tab redirect would lose the current page with no frontend route back)
|
|
2323
|
+
console.warn("Popup blocked — cannot open DigiLocker in new tab");
|
|
2324
|
+
setIsInitiatingVerification(false);
|
|
2325
|
+
setVerificationError(
|
|
2326
|
+
"Unable to open DigiLocker in a new tab. Please allow popups for this site and try again."
|
|
2327
|
+
);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Prevent the opened tab from accessing window.opener (security)
|
|
2332
|
+
try { openedWindow.opener = null; } catch { /* cross-origin — safe to ignore */ }
|
|
2333
|
+
} catch (error) {
|
|
2334
|
+
console.error("Failed to open DigiLocker in new tab:", error);
|
|
2335
|
+
setIsInitiatingVerification(false);
|
|
2336
|
+
setVerificationError(
|
|
2337
|
+
"Unable to open DigiLocker. Please allow popups for this site and try again."
|
|
2338
|
+
);
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
setIsInitiatingVerification(false);
|
|
2343
|
+
setIsPollingStatus(true);
|
|
2344
|
+
isPollingStatusRef.current = true;
|
|
2345
|
+
startStatusPolling(response.session_token);
|
|
2346
|
+
} else {
|
|
2347
|
+
// Popup mode - open DigiLocker in new window
|
|
2348
|
+
setIsInitiatingVerification(true);
|
|
2349
|
+
|
|
2350
|
+
const popup = window.open(
|
|
2351
|
+
response.digilocker_redirect_url,
|
|
2352
|
+
"digilocker_verification",
|
|
2353
|
+
"width=600,height=700,scrollbars=yes,resizable=yes"
|
|
2354
|
+
);
|
|
2355
|
+
|
|
2356
|
+
if (popup) {
|
|
2357
|
+
verificationPopupRef.current = popup;
|
|
2358
|
+
startPopupMonitoring(popup);
|
|
2359
|
+
startStatusPolling(response.session_token);
|
|
2360
|
+
// Keep isInitiatingVerification true while polling
|
|
2361
|
+
} else {
|
|
2362
|
+
// Popup blocked - fall back to redirect
|
|
2363
|
+
setIsInitiatingVerification(false);
|
|
2364
|
+
setVerificationError(
|
|
2365
|
+
"Popup blocked. Please allow popups for this site or use redirect mode."
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
1661
2369
|
} catch (error) {
|
|
1662
2370
|
// Handle API errors
|
|
1663
2371
|
const err = error as Error & { status?: number };
|
|
2372
|
+
setIsInitiatingVerification(false);
|
|
1664
2373
|
|
|
1665
2374
|
if (err.status === 422) {
|
|
1666
2375
|
// Validation error from server - try to extract field-specific errors
|
|
@@ -1673,23 +2382,30 @@ export const RedactoNoticeConsent = ({
|
|
|
1673
2382
|
} else {
|
|
1674
2383
|
setGuardianFormErrors({ general: err.message });
|
|
1675
2384
|
}
|
|
2385
|
+
// Go back to guardian form to show errors
|
|
2386
|
+
setShowVerificationScreen(false);
|
|
2387
|
+
setShowGuardianForm(true);
|
|
1676
2388
|
} else {
|
|
1677
2389
|
// Other errors - show general error
|
|
1678
2390
|
setGuardianFormErrors({
|
|
1679
2391
|
general:
|
|
1680
2392
|
err.message ||
|
|
1681
|
-
"Failed to
|
|
2393
|
+
"Failed to initiate verification. Please try again.",
|
|
1682
2394
|
});
|
|
2395
|
+
setShowVerificationScreen(false);
|
|
2396
|
+
setShowGuardianForm(true);
|
|
1683
2397
|
onError?.(err);
|
|
1684
2398
|
}
|
|
1685
2399
|
} finally {
|
|
1686
2400
|
setIsSubmittingGuardian(false);
|
|
1687
2401
|
}
|
|
1688
|
-
}, [accessToken, baseUrl, guardianFormData, onError]);
|
|
2402
|
+
}, [accessToken, baseUrl, guardianFormData, digilockerMode, digilockerCallbackUrl, selectedPurposes, selectedDataElements, noticeId, onError]);
|
|
1689
2403
|
|
|
1690
2404
|
// Age verification handlers
|
|
1691
2405
|
const handleAgeVerificationYes = useCallback(() => {
|
|
1692
2406
|
setShowAgeVerification(false);
|
|
2407
|
+
setIsMinorFlow(false);
|
|
2408
|
+
setSelfDeclaredAdult(true);
|
|
1693
2409
|
// User is 18+, proceed directly to consent
|
|
1694
2410
|
}, []);
|
|
1695
2411
|
|
|
@@ -1699,6 +2415,299 @@ export const RedactoNoticeConsent = ({
|
|
|
1699
2415
|
// User is under 18, show guardian form
|
|
1700
2416
|
}, []);
|
|
1701
2417
|
|
|
2418
|
+
const handleVerificationContinue = useCallback(() => {
|
|
2419
|
+
// Transition from verification success screen to consent form
|
|
2420
|
+
setShowVerificationScreen(false);
|
|
2421
|
+
setIsVerificationComplete(false);
|
|
2422
|
+
}, []);
|
|
2423
|
+
|
|
2424
|
+
// Guardian verification handlers
|
|
2425
|
+
const handlePopupClosed = useCallback(async () => {
|
|
2426
|
+
// Stop the popup monitor
|
|
2427
|
+
if (popupCheckIntervalRef.current) {
|
|
2428
|
+
clearInterval(popupCheckIntervalRef.current);
|
|
2429
|
+
popupCheckIntervalRef.current = null;
|
|
2430
|
+
}
|
|
2431
|
+
// Stop status polling timer
|
|
2432
|
+
if (statusPollIntervalRef.current) {
|
|
2433
|
+
clearTimeout(statusPollIntervalRef.current);
|
|
2434
|
+
statusPollIntervalRef.current = null;
|
|
2435
|
+
}
|
|
2436
|
+
// Clean up visibilitychange listener
|
|
2437
|
+
pollingCleanupRef.current?.();
|
|
2438
|
+
pollingCleanupRef.current = null;
|
|
2439
|
+
// Null out popup ref
|
|
2440
|
+
verificationPopupRef.current = null;
|
|
2441
|
+
|
|
2442
|
+
// Use ref to avoid stale closure — startPopupMonitoring installs interval
|
|
2443
|
+
// before startStatusPolling flips isPollingStatus state
|
|
2444
|
+
if (!isPollingStatusRef.current) return;
|
|
2445
|
+
|
|
2446
|
+
// Perform one final status check — the user may have completed verification
|
|
2447
|
+
// right before closing the popup
|
|
2448
|
+
const sessionToken = verificationSessionTokenRef.current;
|
|
2449
|
+
if (sessionToken) {
|
|
2450
|
+
try {
|
|
2451
|
+
const response = await verifyGuardianStatus({
|
|
2452
|
+
accessToken,
|
|
2453
|
+
baseUrl,
|
|
2454
|
+
sessionToken,
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
if (response.status === "verified") {
|
|
2458
|
+
const ref = response.verification_reference || response.guardian_details?.verification_reference;
|
|
2459
|
+
if (ref) {
|
|
2460
|
+
setVerificationReference(ref);
|
|
2461
|
+
setIsVerificationComplete(true);
|
|
2462
|
+
setIsPollingStatus(false);
|
|
2463
|
+
isPollingStatusRef.current = false;
|
|
2464
|
+
setIsInitiatingVerification(false);
|
|
2465
|
+
clearConsentState();
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
} catch {
|
|
2470
|
+
// Final check failed — fall through to show error
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// Verification not complete — mark as failure
|
|
2475
|
+
setVerificationError(
|
|
2476
|
+
"Verification window was closed. Please try again."
|
|
2477
|
+
);
|
|
2478
|
+
setIsPollingStatus(false);
|
|
2479
|
+
isPollingStatusRef.current = false;
|
|
2480
|
+
setIsInitiatingVerification(false);
|
|
2481
|
+
}, [accessToken, baseUrl]);
|
|
2482
|
+
|
|
2483
|
+
const startPopupMonitoring = useCallback((popup: Window) => {
|
|
2484
|
+
// Check every 500ms if popup is closed
|
|
2485
|
+
popupCheckIntervalRef.current = window.setInterval(() => {
|
|
2486
|
+
if (popup.closed) {
|
|
2487
|
+
handlePopupClosed();
|
|
2488
|
+
}
|
|
2489
|
+
}, 500);
|
|
2490
|
+
}, [handlePopupClosed]);
|
|
2491
|
+
|
|
2492
|
+
/** Map error_code to user-friendly message */
|
|
2493
|
+
const getVerificationErrorMessage = useCallback(
|
|
2494
|
+
(errorCode: string | null, fallbackError: string | null): string => {
|
|
2495
|
+
switch (errorCode) {
|
|
2496
|
+
case "GUARDIAN_UNDER_18":
|
|
2497
|
+
return "The guardian must be 18 years or older. Please provide details of a different guardian.";
|
|
2498
|
+
case "TOKEN_FAILED":
|
|
2499
|
+
return "DigiLocker verification could not be completed. Please try again.";
|
|
2500
|
+
case "DIGILOCKER_AUTH_FAILED":
|
|
2501
|
+
return "DigiLocker authorization was cancelled or failed. Please try again.";
|
|
2502
|
+
case "NO_AUTH_CODE":
|
|
2503
|
+
return "DigiLocker did not return an authorization code. Please try again.";
|
|
2504
|
+
case "SESSION_EXPIRED":
|
|
2505
|
+
return "Verification session has expired. Please try again.";
|
|
2506
|
+
default:
|
|
2507
|
+
return fallbackError || "Verification failed. Please try again.";
|
|
2508
|
+
}
|
|
2509
|
+
},
|
|
2510
|
+
[]
|
|
2511
|
+
);
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Poll verification status every 3s, hard stop at 2 minutes.
|
|
2515
|
+
* Pauses when tab is hidden (user is on DigiLocker tab), resumes on return.
|
|
2516
|
+
*/
|
|
2517
|
+
const startStatusPolling = useCallback(
|
|
2518
|
+
(token: string) => {
|
|
2519
|
+
verificationSessionTokenRef.current = token;
|
|
2520
|
+
setIsPollingStatus(true);
|
|
2521
|
+
isPollingStatusRef.current = true;
|
|
2522
|
+
setVerificationError(null);
|
|
2523
|
+
setVerificationErrorCode(null);
|
|
2524
|
+
setCanRetryVerification(false);
|
|
2525
|
+
|
|
2526
|
+
let attempts = 0;
|
|
2527
|
+
let consecutiveErrors = 0;
|
|
2528
|
+
let paused = false;
|
|
2529
|
+
const MAX_ATTEMPTS = 40; // 40 × 3s = 2 minutes
|
|
2530
|
+
const MAX_CONSECUTIVE_ERRORS = 3;
|
|
2531
|
+
const POLL_INTERVAL = 3000;
|
|
2532
|
+
|
|
2533
|
+
const stopPolling = () => {
|
|
2534
|
+
if (statusPollIntervalRef.current) {
|
|
2535
|
+
clearTimeout(statusPollIntervalRef.current);
|
|
2536
|
+
statusPollIntervalRef.current = null;
|
|
2537
|
+
}
|
|
2538
|
+
if (popupCheckIntervalRef.current) {
|
|
2539
|
+
clearInterval(popupCheckIntervalRef.current);
|
|
2540
|
+
popupCheckIntervalRef.current = null;
|
|
2541
|
+
}
|
|
2542
|
+
verificationPopupRef.current?.close();
|
|
2543
|
+
verificationPopupRef.current = null;
|
|
2544
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
2545
|
+
pollingCleanupRef.current = null;
|
|
2546
|
+
setIsPollingStatus(false);
|
|
2547
|
+
isPollingStatusRef.current = false;
|
|
2548
|
+
setIsInitiatingVerification(false);
|
|
2549
|
+
};
|
|
2550
|
+
|
|
2551
|
+
const setError = (message: string, errorCode: string | null, canRetry: boolean) => {
|
|
2552
|
+
stopPolling();
|
|
2553
|
+
setVerificationError(message);
|
|
2554
|
+
setVerificationErrorCode(errorCode);
|
|
2555
|
+
setCanRetryVerification(canRetry);
|
|
2556
|
+
};
|
|
2557
|
+
|
|
2558
|
+
const poll = async () => {
|
|
2559
|
+
if (paused) return;
|
|
2560
|
+
|
|
2561
|
+
attempts++;
|
|
2562
|
+
|
|
2563
|
+
if (attempts > MAX_ATTEMPTS) {
|
|
2564
|
+
setError(
|
|
2565
|
+
"Verification is taking longer than expected. Please try again.",
|
|
2566
|
+
"SESSION_EXPIRED",
|
|
2567
|
+
true
|
|
2568
|
+
);
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
try {
|
|
2573
|
+
const response = await verifyGuardianStatus({
|
|
2574
|
+
accessToken,
|
|
2575
|
+
baseUrl,
|
|
2576
|
+
sessionToken: token,
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
// Reset consecutive errors on any successful API call
|
|
2580
|
+
consecutiveErrors = 0;
|
|
2581
|
+
|
|
2582
|
+
if (response.status === "verified") {
|
|
2583
|
+
stopPolling();
|
|
2584
|
+
const ref = response.verification_reference || response.guardian_details?.verification_reference;
|
|
2585
|
+
if (!ref) {
|
|
2586
|
+
setError(
|
|
2587
|
+
"Verification completed but no reference received. Please try again.",
|
|
2588
|
+
null,
|
|
2589
|
+
true
|
|
2590
|
+
);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
setVerificationReference(ref);
|
|
2594
|
+
setIsVerificationComplete(true);
|
|
2595
|
+
clearConsentState();
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
if (response.status === "failed" || response.status === "expired") {
|
|
2600
|
+
setError(
|
|
2601
|
+
getVerificationErrorMessage(response.error_code, response.error),
|
|
2602
|
+
response.error_code,
|
|
2603
|
+
response.can_retry
|
|
2604
|
+
);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// pending / in_progress — poll again
|
|
2609
|
+
if (!paused) {
|
|
2610
|
+
statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
|
|
2611
|
+
}
|
|
2612
|
+
} catch (error) {
|
|
2613
|
+
const err = error as Error & { status?: number };
|
|
2614
|
+
|
|
2615
|
+
if (err.status === 404 || err.status === 410) {
|
|
2616
|
+
setError(
|
|
2617
|
+
"Verification session not found or expired. Please try again.",
|
|
2618
|
+
"SESSION_EXPIRED",
|
|
2619
|
+
true
|
|
2620
|
+
);
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// Transient error — keep polling, fail after consecutive threshold
|
|
2625
|
+
consecutiveErrors++;
|
|
2626
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
2627
|
+
setError(
|
|
2628
|
+
err.message || "Unable to check verification status. Please try again.",
|
|
2629
|
+
null,
|
|
2630
|
+
true
|
|
2631
|
+
);
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2639
|
+
// Pause when tab hidden, resume immediately when visible
|
|
2640
|
+
const onVisibilityChange = () => {
|
|
2641
|
+
if (document.hidden) {
|
|
2642
|
+
paused = true;
|
|
2643
|
+
if (statusPollIntervalRef.current) {
|
|
2644
|
+
clearTimeout(statusPollIntervalRef.current);
|
|
2645
|
+
statusPollIntervalRef.current = null;
|
|
2646
|
+
}
|
|
2647
|
+
} else {
|
|
2648
|
+
paused = false;
|
|
2649
|
+
poll();
|
|
2650
|
+
}
|
|
2651
|
+
};
|
|
2652
|
+
|
|
2653
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
2654
|
+
// Store cleanup so unmount/reset can remove the listener
|
|
2655
|
+
pollingCleanupRef.current = () => {
|
|
2656
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
2657
|
+
};
|
|
2658
|
+
|
|
2659
|
+
// First poll after 3s
|
|
2660
|
+
statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
|
|
2661
|
+
},
|
|
2662
|
+
[accessToken, baseUrl, getVerificationErrorMessage]
|
|
2663
|
+
);
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
const handleBackToGuardianForm = useCallback((clearName = false) => {
|
|
2667
|
+
// Close any open popup
|
|
2668
|
+
verificationPopupRef.current?.close();
|
|
2669
|
+
verificationPopupRef.current = null;
|
|
2670
|
+
|
|
2671
|
+
// Clear all timers
|
|
2672
|
+
if (popupCheckIntervalRef.current) {
|
|
2673
|
+
clearInterval(popupCheckIntervalRef.current);
|
|
2674
|
+
popupCheckIntervalRef.current = null;
|
|
2675
|
+
}
|
|
2676
|
+
if (statusPollIntervalRef.current) {
|
|
2677
|
+
clearTimeout(statusPollIntervalRef.current);
|
|
2678
|
+
statusPollIntervalRef.current = null;
|
|
2679
|
+
}
|
|
2680
|
+
if (autoTransitionTimerRef.current) {
|
|
2681
|
+
clearTimeout(autoTransitionTimerRef.current);
|
|
2682
|
+
autoTransitionTimerRef.current = null;
|
|
2683
|
+
}
|
|
2684
|
+
if (digilockerRedirectTimeoutRef.current) {
|
|
2685
|
+
clearTimeout(digilockerRedirectTimeoutRef.current);
|
|
2686
|
+
digilockerRedirectTimeoutRef.current = null;
|
|
2687
|
+
}
|
|
2688
|
+
// Clean up visibilitychange listener
|
|
2689
|
+
pollingCleanupRef.current?.();
|
|
2690
|
+
pollingCleanupRef.current = null;
|
|
2691
|
+
|
|
2692
|
+
// Reset verification state
|
|
2693
|
+
setShowVerificationScreen(false);
|
|
2694
|
+
setShowGuardianForm(true);
|
|
2695
|
+
setVerificationError(null);
|
|
2696
|
+
setVerificationErrorCode(null);
|
|
2697
|
+
setCanRetryVerification(false);
|
|
2698
|
+
setIsInitiatingVerification(false);
|
|
2699
|
+
setIsPollingStatus(false);
|
|
2700
|
+
isPollingStatusRef.current = false;
|
|
2701
|
+
setIsVerificationComplete(false);
|
|
2702
|
+
|
|
2703
|
+
// For GUARDIAN_UNDER_18: clear name so user enters a different guardian
|
|
2704
|
+
if (clearName) {
|
|
2705
|
+
setGuardianFormData((prev) => ({ ...prev, guardianName: "" }));
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
clearConsentState();
|
|
2709
|
+
}, []);
|
|
2710
|
+
|
|
1702
2711
|
const availableLanguages = useMemo(() => {
|
|
1703
2712
|
if (!content) return [language];
|
|
1704
2713
|
|
|
@@ -2328,9 +3337,77 @@ export const RedactoNoticeConsent = ({
|
|
|
2328
3337
|
}
|
|
2329
3338
|
// Stop and cleanup audio
|
|
2330
3339
|
stopAudio();
|
|
3340
|
+
// Cleanup verification intervals, popup, and visibilitychange listener
|
|
3341
|
+
if (popupCheckIntervalRef.current) {
|
|
3342
|
+
clearInterval(popupCheckIntervalRef.current);
|
|
3343
|
+
}
|
|
3344
|
+
if (statusPollIntervalRef.current) {
|
|
3345
|
+
clearTimeout(statusPollIntervalRef.current);
|
|
3346
|
+
}
|
|
3347
|
+
pollingCleanupRef.current?.();
|
|
3348
|
+
pollingCleanupRef.current = null;
|
|
3349
|
+
verificationPopupRef.current?.close();
|
|
3350
|
+
if (autoTransitionTimerRef.current) {
|
|
3351
|
+
clearTimeout(autoTransitionTimerRef.current);
|
|
3352
|
+
autoTransitionTimerRef.current = null;
|
|
3353
|
+
}
|
|
3354
|
+
if (digilockerRedirectTimeoutRef.current) {
|
|
3355
|
+
clearTimeout(digilockerRedirectTimeoutRef.current);
|
|
3356
|
+
digilockerRedirectTimeoutRef.current = null;
|
|
3357
|
+
}
|
|
2331
3358
|
};
|
|
2332
3359
|
}, [stopAudio]);
|
|
2333
3360
|
|
|
3361
|
+
// Restore state from redirect flow (when user returns from DigiLocker)
|
|
3362
|
+
useEffect(() => {
|
|
3363
|
+
const persistedState = getConsentState();
|
|
3364
|
+
if (!persistedState) return;
|
|
3365
|
+
|
|
3366
|
+
// Validate persisted state belongs to this notice and hasn't expired
|
|
3367
|
+
if (persistedState.noticeId && persistedState.noticeId !== noticeId) {
|
|
3368
|
+
clearConsentState();
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
const REDIRECT_STATE_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes
|
|
3372
|
+
if (persistedState.timestamp) {
|
|
3373
|
+
const stateAge = Date.now() - new Date(persistedState.timestamp).getTime();
|
|
3374
|
+
if (stateAge > REDIRECT_STATE_MAX_AGE_MS) {
|
|
3375
|
+
clearConsentState();
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
// Check if this is a redirect return
|
|
3381
|
+
if (persistedState.flowState === "digilocker-pending" && persistedState.sessionToken) {
|
|
3382
|
+
// Resume verification status polling
|
|
3383
|
+
setShowVerificationScreen(true);
|
|
3384
|
+
setShowGuardianForm(false);
|
|
3385
|
+
setShowAgeVerification(false);
|
|
3386
|
+
|
|
3387
|
+
// Start polling for status
|
|
3388
|
+
startStatusPolling(persistedState.sessionToken);
|
|
3389
|
+
} else if (persistedState.verificationResult === "verified" && persistedState.verificationReference) {
|
|
3390
|
+
// Verification completed during redirect, show success screen with Continue button
|
|
3391
|
+
setVerificationReference(persistedState.verificationReference);
|
|
3392
|
+
setShowVerificationScreen(true);
|
|
3393
|
+
setIsVerificationComplete(true);
|
|
3394
|
+
setShowGuardianForm(false);
|
|
3395
|
+
setShowAgeVerification(false);
|
|
3396
|
+
clearConsentState();
|
|
3397
|
+
} else if (persistedState.verificationResult === "failed" || persistedState.verificationResult === "expired") {
|
|
3398
|
+
// Verification failed during redirect, show error
|
|
3399
|
+
setShowVerificationScreen(true);
|
|
3400
|
+
setShowGuardianForm(false);
|
|
3401
|
+
setShowAgeVerification(false);
|
|
3402
|
+
setVerificationError(
|
|
3403
|
+
persistedState.verificationResult === "expired"
|
|
3404
|
+
? "Verification session has expired. Please try again."
|
|
3405
|
+
: "Verification failed. Please try again."
|
|
3406
|
+
);
|
|
3407
|
+
clearConsentState();
|
|
3408
|
+
}
|
|
3409
|
+
}, [startStatusPolling, noticeId]);
|
|
3410
|
+
|
|
2334
3411
|
// =============================================================================
|
|
2335
3412
|
// RENDER
|
|
2336
3413
|
// =============================================================================
|
|
@@ -2511,6 +3588,28 @@ export const RedactoNoticeConsent = ({
|
|
|
2511
3588
|
logoUrl={content?.logo_url || logo}
|
|
2512
3589
|
/>
|
|
2513
3590
|
</div>
|
|
3591
|
+
) : showVerificationScreen ? (
|
|
3592
|
+
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
3593
|
+
<VerificationScreen
|
|
3594
|
+
onBack={handleBackToGuardianForm}
|
|
3595
|
+
onContinue={handleVerificationContinue}
|
|
3596
|
+
isInitiating={isInitiatingVerification}
|
|
3597
|
+
isPolling={isPollingStatus}
|
|
3598
|
+
isVerificationComplete={isVerificationComplete}
|
|
3599
|
+
isAutoTransitioning={isAutoTransitioning}
|
|
3600
|
+
error={verificationError}
|
|
3601
|
+
errorCode={verificationErrorCode}
|
|
3602
|
+
canRetry={canRetryVerification}
|
|
3603
|
+
styles={styles}
|
|
3604
|
+
responsiveStyles={responsiveStyles}
|
|
3605
|
+
settings={settings}
|
|
3606
|
+
componentHeadingStyle={componentHeadingStyle}
|
|
3607
|
+
componentTextStyle={componentTextStyle}
|
|
3608
|
+
acceptButtonStyle={acceptButtonStyle}
|
|
3609
|
+
declineButtonStyle={declineButtonStyle}
|
|
3610
|
+
logoUrl={content?.logo_url || logo}
|
|
3611
|
+
/>
|
|
3612
|
+
</div>
|
|
2514
3613
|
) : (
|
|
2515
3614
|
<div style={{ ...styles.content, ...responsiveStyles.content }}>
|
|
2516
3615
|
{errorMessage && (
|