@redacto.io/consent-sdk-react 10.0.0-beta.1 → 10.0.0-beta.2

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/privacy-center.d.mts +2 -0
  3. package/dist/privacy-center.d.ts +2 -0
  4. package/dist/privacy-center.js +93 -27
  5. package/dist/privacy-center.mjs +93 -27
  6. package/package.json +1 -1
  7. package/src/RedactoPrivacyCenter/api/actions.ts +14 -1
  8. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +7 -0
  9. package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +19 -2
  10. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
  11. package/src/RedactoPrivacyCenter/lib/types.ts +9 -0
  12. package/src/RedactoPrivacyCenter/locales/as/translation.json +3 -1
  13. package/src/RedactoPrivacyCenter/locales/bho/translation.json +3 -1
  14. package/src/RedactoPrivacyCenter/locales/bn/translation.json +3 -1
  15. package/src/RedactoPrivacyCenter/locales/brx/translation.json +3 -1
  16. package/src/RedactoPrivacyCenter/locales/doi/translation.json +3 -1
  17. package/src/RedactoPrivacyCenter/locales/en/translation.json +3 -1
  18. package/src/RedactoPrivacyCenter/locales/gom/translation.json +3 -1
  19. package/src/RedactoPrivacyCenter/locales/gu/translation.json +3 -1
  20. package/src/RedactoPrivacyCenter/locales/hi/translation.json +3 -1
  21. package/src/RedactoPrivacyCenter/locales/kn/translation.json +3 -1
  22. package/src/RedactoPrivacyCenter/locales/ks/translation.json +3 -1
  23. package/src/RedactoPrivacyCenter/locales/mai/translation.json +3 -1
  24. package/src/RedactoPrivacyCenter/locales/ml/translation.json +3 -1
  25. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +3 -1
  26. package/src/RedactoPrivacyCenter/locales/mr/translation.json +3 -1
  27. package/src/RedactoPrivacyCenter/locales/ne/translation.json +3 -1
  28. package/src/RedactoPrivacyCenter/locales/or/translation.json +3 -1
  29. package/src/RedactoPrivacyCenter/locales/pa/translation.json +3 -1
  30. package/src/RedactoPrivacyCenter/locales/sa/translation.json +3 -1
  31. package/src/RedactoPrivacyCenter/locales/sat/translation.json +3 -1
  32. package/src/RedactoPrivacyCenter/locales/sd/translation.json +3 -1
  33. package/src/RedactoPrivacyCenter/locales/ta/translation.json +3 -1
  34. package/src/RedactoPrivacyCenter/locales/te/translation.json +3 -1
  35. package/src/RedactoPrivacyCenter/locales/ur/translation.json +3 -1
  36. package/tests/ManageConsentErrorMapping.test.tsx +85 -0
  37. package/tests/RevocablePurpose.test.tsx +146 -0
  38. package/tests/RevokeWarningMessage.test.tsx +151 -0
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} چھُ اکہ کھۄتہ زیادٕ پروفائلن سٟتؠ جوڑنہ آمُت۔ یُس توہہِ وُچھُن چھِو یژھان سُ ژارٕو۔",
337
337
  "contactLinkedToMultipleProfiles": "یہ رابطہ چھُ اکہ کھۄتہ زیادٕ پروفائلن سٟتؠ جوڑنہ آمُت۔ یُس توہہِ وُچھُن چھِو یژھان سُ ژارٕو۔",
338
338
  "signingInEllipsis": "سائن اِن گژھان چھُ…",
339
- "couldNotSwitchProfile": "تہِ پروفائلس منٛز سوِچ کرُن آو نہ۔ مہربانی کرِتھ دوبارٕ کوشش کریو۔"
339
+ "couldNotSwitchProfile": "تہِ پروفائلس منٛز سوِچ کرُن آو نہ۔ مہربانی کرِتھ دوبارٕ کوشش کریو۔",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} एकाधिक प्रोफाइलसँ जुड़ल अछि। जे अहाँ देखय चाहैत छी ओकरा चुनू।",
337
337
  "contactLinkedToMultipleProfiles": "ई संपर्क एकाधिक प्रोफाइलसँ जुड़ल अछि। जे अहाँ देखय चाहैत छी ओकरा चुनू।",
338
338
  "signingInEllipsis": "साइन इन भ' रहल अछि…",
339
- "couldNotSwitchProfile": "ओ प्रोफाइल पर स्विच नहि भ' सकल। कृपया फेर सँ प्रयास करू।"
339
+ "couldNotSwitchProfile": "ओ प्रोफाइल पर स्विच नहि भ' सकल। कृपया फेर सँ प्रयास करू।",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} ഒന്നിലധികം പ്രൊഫൈലുകളുമായി ബന്ധിപ്പിച്ചിരിക്കുന്നു. നിങ്ങൾ കാണാൻ ആഗ്രഹിക്കുന്നത് തിരഞ്ഞെടുക്കുക.",
337
337
  "contactLinkedToMultipleProfiles": "ഈ കോൺടാക്റ്റ് ഒന്നിലധികം പ്രൊഫൈലുകളുമായി ബന്ധിപ്പിച്ചിരിക്കുന്നു. നിങ്ങൾ കാണാൻ ആഗ്രഹിക്കുന്നത് തിരഞ്ഞെടുക്കുക.",
338
338
  "signingInEllipsis": "സൈൻ ഇൻ ചെയ്യുന്നു…",
339
- "couldNotSwitchProfile": "ആ പ്രൊഫൈലിലേക്ക് മാറാൻ കഴിഞ്ഞില്ല. വീണ്ടും ശ്രമിക്കുക."
339
+ "couldNotSwitchProfile": "ആ പ്രൊഫൈലിലേക്ക് മാറാൻ കഴിഞ്ഞില്ല. വീണ്ടും ശ്രമിക്കുക.",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} অসি প্রোফাইল অমদগী হেন্না লোইননবা লৈ। নহাক্না য়েংনিংবা অদু খনবিয়ু।",
337
337
  "contactLinkedToMultipleProfiles": "মসি কন্টেক্ট অসি প্রোফাইল অমদগী হেন্না লোইননবা লৈ। নহাক্না য়েংনিংবা অদু খনবিয়ু।",
338
338
  "signingInEllipsis": "সাইন ইন তৌরি…",
339
- "couldNotSwitchProfile": "প্রোফাইল অদুদা হোংদোকপা ঙমদ্রে। চানবীদুনা অমুক হন্না হোৎনবিয়ু।"
339
+ "couldNotSwitchProfile": "প্রোফাইল অদুদা হোংদোকপা ঙমদ্রে। চানবীদুনা অমুক হন্না হোৎনবিয়ু।",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} एकापेक्षा जास्त प्रोफाइलशी जोडलेला आहे. तुम्हाला जी पाहायची आहे ती निवडा.",
337
337
  "contactLinkedToMultipleProfiles": "हा संपर्क एकापेक्षा जास्त प्रोफाइलशी जोडलेला आहे. तुम्हाला जी पाहायची आहे ती निवडा.",
338
338
  "signingInEllipsis": "साइन इन करत आहे…",
339
- "couldNotSwitchProfile": "त्या प्रोफाइलवर स्विच करता आले नाही. कृपया पुन्हा प्रयत्न करा."
339
+ "couldNotSwitchProfile": "त्या प्रोफाइलवर स्विच करता आले नाही. कृपया पुन्हा प्रयत्न करा.",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} एकभन्दा बढी प्रोफाइलसँग जोडिएको छ। तपाईंले हेर्न चाहनुभएको छान्नुहोस्।",
337
337
  "contactLinkedToMultipleProfiles": "यो सम्पर्क एकभन्दा बढी प्रोफाइलसँग जोडिएको छ। तपाईंले हेर्न चाहनुभएको छान्नुहोस्।",
338
338
  "signingInEllipsis": "साइन इन हुँदैछ…",
339
- "couldNotSwitchProfile": "त्यो प्रोफाइलमा स्विच गर्न सकिएन। कृपया फेरि प्रयास गर्नुहोस्।"
339
+ "couldNotSwitchProfile": "त्यो प्रोफाइलमा स्विच गर्न सकिएन। कृपया फेरि प्रयास गर्नुहोस्।",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} ଏକାଧିକ ପ୍ରୋଫାଇଲ୍ ସହିତ ଯୋଡ଼ାଯାଇଛି। ଆପଣ ଦେଖିବାକୁ ଚାହୁଁଥିବାଟି ବାଛନ୍ତୁ।",
337
337
  "contactLinkedToMultipleProfiles": "ଏହି ଯୋଗାଯୋଗ ଏକାଧିକ ପ୍ରୋଫାଇଲ୍ ସହିତ ଯୋଡ଼ାଯାଇଛି। ଆପଣ ଦେଖିବାକୁ ଚାହୁଁଥିବାଟି ବାଛନ୍ତୁ।",
338
338
  "signingInEllipsis": "ସାଇନ୍ ଇନ୍ ହେଉଛି…",
339
- "couldNotSwitchProfile": "ସେହି ପ୍ରୋଫାଇଲକୁ ସ୍ୱିଚ୍ କରାଯାଇପାରିଲା ନାହିଁ। ଦୟାକରି ପୁନର୍ବାର ଚେଷ୍ଟା କରନ୍ତୁ।"
339
+ "couldNotSwitchProfile": "ସେହି ପ୍ରୋଫାଇଲକୁ ସ୍ୱିଚ୍ କରାଯାଇପାରିଲା ନାହିଁ। ଦୟାକରି ପୁନର୍ବାର ଚେଷ୍ଟା କରନ୍ତୁ।",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} ਇੱਕ ਤੋਂ ਵੱਧ ਪ੍ਰੋਫਾਈਲਾਂ ਨਾਲ ਜੁੜਿਆ ਹੋਇਆ ਹੈ। ਜੋ ਤੁਸੀਂ ਵੇਖਣਾ ਚਾਹੁੰਦੇ ਹੋ ਉਹ ਚੁਣੋ।",
337
337
  "contactLinkedToMultipleProfiles": "ਇਹ ਸੰਪਰਕ ਇੱਕ ਤੋਂ ਵੱਧ ਪ੍ਰੋਫਾਈਲਾਂ ਨਾਲ ਜੁੜਿਆ ਹੋਇਆ ਹੈ। ਜੋ ਤੁਸੀਂ ਵੇਖਣਾ ਚਾਹੁੰਦੇ ਹੋ ਉਹ ਚੁਣੋ।",
338
338
  "signingInEllipsis": "ਸਾਈਨ ਇਨ ਹੋ ਰਿਹਾ ਹੈ…",
339
- "couldNotSwitchProfile": "ਉਸ ਪ੍ਰੋਫਾਈਲ 'ਤੇ ਸਵਿੱਚ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।"
339
+ "couldNotSwitchProfile": "ਉਸ ਪ੍ਰੋਫਾਈਲ 'ਤੇ ਸਵਿੱਚ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} एकाधिकैः प्रोफ़ाइलैः सह सम्बद्धः अस्ति। यत् द्रष्टुम् इच्छति तत् चिनोतु।",
337
337
  "contactLinkedToMultipleProfiles": "अयं सम्पर्कः एकाधिकैः प्रोफ़ाइलैः सह सम्बद्धः अस्ति। यत् द्रष्टुम् इच्छति तत् चिनोतु।",
338
338
  "signingInEllipsis": "प्रवेशः क्रियते…",
339
- "couldNotSwitchProfile": "तां प्रोफ़ाइलं प्रति परिवर्तनं न शक्यते स्म। कृपया पुनः प्रयत्नं करोतु।"
339
+ "couldNotSwitchProfile": "तां प्रोफ़ाइलं प्रति परिवर्तनं न शक्यते स्म। कृपया पुनः प्रयत्नं करोतु।",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} ᱫᱚ ᱢᱤᱫ ᱠᱷᱚᱱ ᱰᱷᱮᱨ ᱯᱨᱳᱯᱷᱟᱭᱤᱞ ᱥᱟᱶ ᱡᱩᱰᱟᱹᱣ ᱟᱠᱟᱱᱟ᱾ ᱟᱢ ᱧᱮᱞ ᱥᱟᱱᱟᱢ ᱚᱱᱟ ᱵᱟᱪᱷᱟᱣ ᱢᱮ᱾",
337
337
  "contactLinkedToMultipleProfiles": "ᱱᱳᱶᱟ ᱠᱚᱱᱴᱮᱠᱴ ᱫᱚ ᱢᱤᱫ ᱠᱷᱚᱱ ᱰᱷᱮᱨ ᱯᱨᱳᱯᱷᱟᱭᱤᱞ ᱥᱟᱶ ᱡᱩᱰᱟᱹᱣ ᱟᱠᱟᱱᱟ᱾ ᱟᱢ ᱧᱮᱞ ᱥᱟᱱᱟᱢ ᱚᱱᱟ ᱵᱟᱪᱷᱟᱣ ᱢᱮ᱾",
338
338
  "signingInEllipsis": "ᱥᱟᱭᱤᱱ ᱤᱱ ᱮᱫᱟᱭ…",
339
- "couldNotSwitchProfile": "ᱚᱱᱟ ᱯᱨᱳᱯᱷᱟᱭᱤᱞ ᱛᱮ ᱥᱩᱭᱪ ᱵᱟᱝ ᱦᱩᱭ ᱞᱮᱱᱟ᱾ ᱫᱟᱭᱟ ᱠᱟᱛᱮ ᱫᱩᱠᱟᱹᱛᱮ ᱠᱩᱨᱩᱢᱩᱴᱩ ᱢᱮ᱾"
339
+ "couldNotSwitchProfile": "ᱚᱱᱟ ᱯᱨᱳᱯᱷᱟᱭᱤᱞ ᱛᱮ ᱥᱩᱭᱪ ᱵᱟᱝ ᱦᱩᱭ ᱞᱮᱱᱟ᱾ ᱫᱟᱭᱟ ᱠᱟᱛᱮ ᱫᱩᱠᱟᱹᱛᱮ ᱠᱩᱨᱩᱢᱩᱴᱩ ᱢᱮ᱾",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} هڪ کان وڌيڪ پروفائلن سان ڳنڍيل آهي. جيڪو توهان ڏسڻ چاهيو ٿا اهو چونڊيو.",
337
337
  "contactLinkedToMultipleProfiles": "هي رابطو هڪ کان وڌيڪ پروفائلن سان ڳنڍيل آهي. جيڪو توهان ڏسڻ چاهيو ٿا اهو چونڊيو.",
338
338
  "signingInEllipsis": "سائن ان ٿي رهيو آهي…",
339
- "couldNotSwitchProfile": "ان پروفائل تي سوئچ نه ٿي سگهيو. مهرباني ڪري ٻيهر ڪوشش ڪريو."
339
+ "couldNotSwitchProfile": "ان پروفائل تي سوئچ نه ٿي سگهيو. مهرباني ڪري ٻيهر ڪوشش ڪريو.",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} ஒன்றுக்கு மேற்பட்ட சுயவிவரங்களுடன் இணைக்கப்பட்டுள்ளது. நீங்கள் பார்க்க விரும்புவதைத் தேர்ந்தெடுக்கவும்.",
337
337
  "contactLinkedToMultipleProfiles": "இந்தத் தொடர்பு ஒன்றுக்கு மேற்பட்ட சுயவிவரங்களுடன் இணைக்கப்பட்டுள்ளது. நீங்கள் பார்க்க விரும்புவதைத் தேர்ந்தெடுக்கவும்.",
338
338
  "signingInEllipsis": "உள்நுழைகிறது…",
339
- "couldNotSwitchProfile": "அந்தச் சுயவிவரத்திற்கு மாற முடியவில்லை. மீண்டும் முயற்சிக்கவும்."
339
+ "couldNotSwitchProfile": "அந்தச் சுயவிவரத்திற்கு மாற முடியவில்லை. மீண்டும் முயற்சிக்கவும்.",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} ఒకటి కంటే ఎక్కువ ప్రొఫైల్‌లతో అనుసంధానించబడింది. మీరు చూడాలనుకుంటున్నదాన్ని ఎంచుకోండి.",
337
337
  "contactLinkedToMultipleProfiles": "ఈ సంప్రదింపు ఒకటి కంటే ఎక్కువ ప్రొఫైల్‌లతో అనుసంధానించబడింది. మీరు చూడాలనుకుంటున్నదాన్ని ఎంచుకోండి.",
338
338
  "signingInEllipsis": "సైన్ ఇన్ అవుతోంది…",
339
- "couldNotSwitchProfile": "ఆ ప్రొఫైల్‌కు మారడం సాధ్యం కాలేదు. దయచేసి మళ్లీ ప్రయత్నించండి."
339
+ "couldNotSwitchProfile": "ఆ ప్రొఫైల్‌కు మారడం సాధ్యం కాలేదు. దయచేసి మళ్లీ ప్రయత్నించండి.",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -336,5 +336,7 @@
336
336
  "emailLinkedToMultipleProfiles": "{{email}} ایک سے زیادہ پروفائلز سے منسلک ہے۔ جسے آپ دیکھنا چاہتے ہیں اسے منتخب کریں۔",
337
337
  "contactLinkedToMultipleProfiles": "یہ رابطہ ایک سے زیادہ پروفائلز سے منسلک ہے۔ جسے آپ دیکھنا چاہتے ہیں اسے منتخب کریں۔",
338
338
  "signingInEllipsis": "سائن ان ہو رہا ہے…",
339
- "couldNotSwitchProfile": "اس پروفائل پر سوئچ نہیں کیا جا سکا۔ براہ کرم دوبارہ کوشش کریں۔"
339
+ "couldNotSwitchProfile": "اس پروفائل پر سوئچ نہیں کیا جا سکا۔ براہ کرم دوبارہ کوشش کریں۔",
340
+ "purposeNotRevocable": "This purpose can't be withdrawn directly.",
341
+ "purposeNotRevocableError": "This purpose can't be withdrawn directly. Please raise a request to remove it."
340
342
  }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { manageConsentServer, ConsentApiError } from '../src/RedactoPrivacyCenter/api/actions';
4
+ import { ApiError } from '../src/RedactoPrivacyCenter/api/fetcher';
5
+ import type { createConsentClient } from '../src/RedactoPrivacyCenter/api/client';
6
+
7
+ type ConsentClient = ReturnType<typeof createConsentClient>;
8
+
9
+ // Build a mock consent client whose `manageConsent` rejects with the supplied
10
+ // error. Only `manageConsent` is exercised by `manageConsentServer`, so the
11
+ // rest of the surface is a cast-through stub.
12
+ const makeClient = (error: unknown): ConsentClient =>
13
+ ({
14
+ manageConsent: () => Promise.reject(error),
15
+ } as unknown as ConsentClient);
16
+
17
+ const baseParams = {
18
+ organizationId: 'org-1',
19
+ workspaceId: 'ws-1',
20
+ purposeId: 'purpose-1',
21
+ contact: 'user@example.com',
22
+ action: 'revoke' as const,
23
+ authToken: 'token-abc',
24
+ };
25
+
26
+ describe('manageConsentServer error mapping — purpose_not_revocable', () => {
27
+ // The consent-server 400 is rendered by a Ninja wrapper as
28
+ // { code: 400, status, detail: { message, code: 'purpose_not_revocable' } }.
29
+ // `privacyCenterApiFetch` preserves the parsed `detail` on the ApiError and
30
+ // sets ApiError.code only from the TOP-LEVEL string code — which here is the
31
+ // integer 400 (dropped, so ApiError.code is undefined). The app-level
32
+ // discriminator therefore lives in `error.detail.code`.
33
+ const realBackody400 = () =>
34
+ new ApiError(
35
+ `[400] ${JSON.stringify(
36
+ { message: 'This purpose cannot be revoked.', code: 'purpose_not_revocable' },
37
+ null,
38
+ 2,
39
+ )}`,
40
+ { status: 400, code: undefined, detail: { message: 'This purpose cannot be revoked.', code: 'purpose_not_revocable' } },
41
+ );
42
+
43
+ it('maps the real backend 400 body (code nested in detail) to PURPOSE_NOT_REVOCABLE', async () => {
44
+ await expect(
45
+ manageConsentServer({ ...baseParams, consentClient: makeClient(realBackody400()) }),
46
+ ).rejects.toMatchObject({
47
+ name: 'ConsentApiError',
48
+ code: 'PURPOSE_NOT_REVOCABLE',
49
+ });
50
+ });
51
+
52
+ it('throws a ConsentApiError instance carrying the original message', async () => {
53
+ const error = realBackody400();
54
+ try {
55
+ await manageConsentServer({ ...baseParams, consentClient: makeClient(error) });
56
+ throw new Error('expected manageConsentServer to reject');
57
+ } catch (caught) {
58
+ expect(caught).toBeInstanceOf(ConsentApiError);
59
+ expect((caught as ConsentApiError).code).toBe('PURPOSE_NOT_REVOCABLE');
60
+ expect((caught as ConsentApiError).message).toContain('purpose_not_revocable');
61
+ }
62
+ });
63
+
64
+ it('still maps when the string code is surfaced at the top level (error.code)', async () => {
65
+ // Defensive: if a backend/wrapper ever exposes the string code at the top
66
+ // level, the original `error?.code === 'purpose_not_revocable'` arm must
67
+ // continue to fire.
68
+ const error = new ApiError('[400] purpose is not revocable', {
69
+ status: 400,
70
+ code: 'purpose_not_revocable',
71
+ });
72
+ await expect(
73
+ manageConsentServer({ ...baseParams, consentClient: makeClient(error) }),
74
+ ).rejects.toMatchObject({ code: 'PURPOSE_NOT_REVOCABLE' });
75
+ });
76
+
77
+ it('does not misclassify an unrelated error as PURPOSE_NOT_REVOCABLE', async () => {
78
+ const error = new ApiError('[500] internal server error', { status: 500 });
79
+ // Unrelated non-auth errors are swallowed to null by handleServerActionError,
80
+ // so the call resolves rather than throwing a ConsentApiError.
81
+ await expect(
82
+ manageConsentServer({ ...baseParams, consentClient: makeClient(error) }),
83
+ ).resolves.toBeNull();
84
+ });
85
+ });
@@ -0,0 +1,146 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+
4
+ // react-i18next is an optional peer dep that is not installed in this
5
+ // package's node_modules. Stub it with a minimal `useTranslation` that
6
+ // resolves the keys used by the components under test, honouring the inline
7
+ // English fallback passed as the 2nd argument (mirrors the real i18next
8
+ // behaviour when a key is missing).
9
+ vi.mock('react-i18next', () => {
10
+ const en: Record<string, string> = {
11
+ modifyButton: 'Modify',
12
+ modifyConsent: 'Modify Consent',
13
+ closeModal: 'Close',
14
+ cancel: 'Cancel',
15
+ revokeConsent: 'Revoke Consent',
16
+ revokeConsentWarning: 'If you revoke this consent, you may lose access.',
17
+ aboutToModifyConsent: 'You are about to modify this consent.',
18
+ noDataElementsAvailable: 'No data elements available',
19
+ purposeNotRevocable: "This purpose can't be withdrawn directly.",
20
+ noActionsAvailable: 'No actions available for this record.',
21
+ };
22
+ const interpolate = (template: string, vars?: Record<string, unknown>) =>
23
+ template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
24
+ const value = vars?.[key];
25
+ return value === undefined || value === null ? '' : String(value);
26
+ });
27
+ // Supports the `t(key, fallbackString)` and `t(key, opts)` call shapes used
28
+ // across the components. When the key is unknown, fall back to the inline
29
+ // English string if one was supplied, otherwise echo the key.
30
+ const t = (key: string, second?: unknown, opts?: Record<string, unknown>) => {
31
+ const fallback = typeof second === 'string' ? second : undefined;
32
+ const interpolationOpts =
33
+ typeof second === 'object' && second !== null
34
+ ? (second as Record<string, unknown>)
35
+ : opts;
36
+ const template = en[key] ?? fallback ?? key;
37
+ return interpolate(template, interpolationOpts);
38
+ };
39
+ return {
40
+ useTranslation: () => ({ t, i18n: { language: 'en' } }),
41
+ };
42
+ });
43
+
44
+ // Icon peer deps — stub the icons used by the modal.
45
+ vi.mock('lucide-react', () => ({
46
+ ChevronDownIcon: () => null,
47
+ X: () => null,
48
+ Smartphone: () => null,
49
+ LaptopMinimal: () => null,
50
+ }));
51
+
52
+ vi.mock('react-icons/pi', () => ({
53
+ PiWarningCircleBold: () => null,
54
+ }));
55
+
56
+ import { renderModifyButton } from '../src/RedactoPrivacyCenter/components/ConsentManagerCells';
57
+ import { ModifyConsentModal } from '../src/RedactoPrivacyCenter/components/ModifyConsentModal';
58
+ import { NavigationProvider } from '../src/RedactoPrivacyCenter/context/NavigationContext';
59
+ import { ConsentStatusEnum } from '../src/RedactoPrivacyCenter/lib/types';
60
+ import type { ConsentModalRecord } from '../src/RedactoPrivacyCenter/lib/types';
61
+
62
+ const makeRecord = (
63
+ overrides: Partial<ConsentModalRecord> = {},
64
+ ): ConsentModalRecord => ({
65
+ purpose_uuid: 'purpose-1',
66
+ name: 'Marketing analytics',
67
+ description: 'Improve product recommendations',
68
+ status: ConsentStatusEnum.ACTIVE,
69
+ given_date: '2026-01-01T00:00:00Z',
70
+ valid_till: null,
71
+ method: 'WEB',
72
+ data_elements: [],
73
+ link_reason: null,
74
+ product_uuid: 'prod-1',
75
+ product_name: 'Acme Loyalty',
76
+ product_description: null,
77
+ nominator: null,
78
+ ...overrides,
79
+ });
80
+
81
+ // A TFunction-compatible stub for the pure render helper (which receives `t`
82
+ // directly rather than via the react-i18next mock). Resolves the handful of
83
+ // keys it uses and honours the inline English fallback like real i18next.
84
+ const tStubKeys: Record<string, string> = {
85
+ modifyButton: 'Modify',
86
+ };
87
+ const tStub = ((key: string, second?: unknown) =>
88
+ tStubKeys[key] ??
89
+ (typeof second === 'string' ? second : key)) as unknown as Parameters<
90
+ typeof renderModifyButton
91
+ >[2];
92
+
93
+ describe('non-revocable purpose', () => {
94
+ it('renders the modify/withdraw control disabled for a non-revocable ACTIVE purpose', () => {
95
+ const record = makeRecord({ revocable: false });
96
+ render(<>{renderModifyButton(record, vi.fn(), tStub)}</>);
97
+
98
+ const button = screen.getByRole('button', { name: 'Modify' });
99
+ expect(button).toBeDisabled();
100
+ expect(button).toHaveAttribute(
101
+ 'title',
102
+ "This purpose can't be withdrawn directly.",
103
+ );
104
+ });
105
+
106
+ it('does not invoke onModify when the disabled non-revocable control is clicked', () => {
107
+ const onModify = vi.fn();
108
+ const record = makeRecord({ revocable: false });
109
+ render(<>{renderModifyButton(record, onModify, tStub)}</>);
110
+
111
+ fireEvent.click(screen.getByRole('button', { name: 'Modify' }));
112
+ expect(onModify).not.toHaveBeenCalled();
113
+ });
114
+ });
115
+
116
+ describe('revocable purpose (regression)', () => {
117
+ it('renders the modify control enabled when revocable is absent (older backends)', () => {
118
+ const record = makeRecord(); // no `revocable` field
119
+ const onModify = vi.fn();
120
+ render(<>{renderModifyButton(record, onModify, tStub)}</>);
121
+
122
+ const button = screen.getByRole('button', { name: 'Modify' });
123
+ expect(button).not.toBeDisabled();
124
+ fireEvent.click(button);
125
+ expect(onModify).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it('offers the revoke action in the modal for a revocable ACTIVE record', () => {
129
+ const record = makeRecord({ revocable: true });
130
+ render(
131
+ <NavigationProvider>
132
+ <ModifyConsentModal
133
+ isOpen
134
+ onClose={vi.fn()}
135
+ selectedRecord={record}
136
+ isLoading={false}
137
+ onManageConsent={vi.fn()}
138
+ />
139
+ </NavigationProvider>,
140
+ );
141
+
142
+ expect(
143
+ screen.getByRole('button', { name: 'Revoke Consent' }),
144
+ ).toBeInTheDocument();
145
+ });
146
+ });
@@ -0,0 +1,151 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+
4
+ // react-i18next is an optional peer dep that is not installed in this
5
+ // package's node_modules. Stub it with a minimal `useTranslation` that
6
+ // resolves the keys used by the components under test, honouring the inline
7
+ // English fallback passed as the 2nd argument (mirrors the real i18next
8
+ // behaviour when a key is missing).
9
+ vi.mock('react-i18next', () => {
10
+ const en: Record<string, string> = {
11
+ modifyConsent: 'Modify Consent',
12
+ closeModal: 'Close',
13
+ cancel: 'Cancel',
14
+ revokeConsent: 'Revoke Consent',
15
+ revokeConsentWarning: 'If you revoke this consent, you may lose access.',
16
+ regrantConsent: 'Regrant Consent',
17
+ regrantConsentWarning: 'Regranting will restore this consent.',
18
+ aboutToModifyConsent: 'You are about to modify this consent.',
19
+ aboutToRegrantConsent: 'You are about to regrant this consent.',
20
+ noDataElementsAvailable: 'No data elements available',
21
+ noActionsAvailable: 'No actions available for this record.',
22
+ };
23
+ const interpolate = (template: string, vars?: Record<string, unknown>) =>
24
+ template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
25
+ const value = vars?.[key];
26
+ return value === undefined || value === null ? '' : String(value);
27
+ });
28
+ // Supports the `t(key, fallbackString)` and `t(key, opts)` call shapes used
29
+ // across the components. When the key is unknown, fall back to the inline
30
+ // English string if one was supplied, otherwise echo the key.
31
+ const t = (key: string, second?: unknown, opts?: Record<string, unknown>) => {
32
+ const fallback = typeof second === 'string' ? second : undefined;
33
+ const interpolationOpts =
34
+ typeof second === 'object' && second !== null
35
+ ? (second as Record<string, unknown>)
36
+ : opts;
37
+ const template = en[key] ?? fallback ?? key;
38
+ return interpolate(template, interpolationOpts);
39
+ };
40
+ return {
41
+ useTranslation: () => ({ t, i18n: { language: 'en' } }),
42
+ };
43
+ });
44
+
45
+ // Icon peer deps — stub the icons used by the modal.
46
+ vi.mock('lucide-react', () => ({
47
+ ChevronDownIcon: () => null,
48
+ X: () => null,
49
+ Smartphone: () => null,
50
+ LaptopMinimal: () => null,
51
+ }));
52
+
53
+ vi.mock('react-icons/pi', () => ({
54
+ PiWarningCircleBold: () => null,
55
+ }));
56
+
57
+ import { ModifyConsentModal } from '../src/RedactoPrivacyCenter/components/ModifyConsentModal';
58
+ import { NavigationProvider } from '../src/RedactoPrivacyCenter/context/NavigationContext';
59
+ import { ConsentStatusEnum } from '../src/RedactoPrivacyCenter/lib/types';
60
+ import type { ConsentModalRecord } from '../src/RedactoPrivacyCenter/lib/types';
61
+
62
+ const DEFAULT_WARNING = 'If you revoke this consent, you may lose access.';
63
+ const CUSTOM_WARNING =
64
+ 'Revoking marketing consent will stop all promotional emails immediately.';
65
+
66
+ const makeRecord = (
67
+ overrides: Partial<ConsentModalRecord> = {},
68
+ ): ConsentModalRecord => ({
69
+ purpose_uuid: 'purpose-1',
70
+ name: 'Marketing analytics',
71
+ description: 'Improve product recommendations',
72
+ status: ConsentStatusEnum.ACTIVE,
73
+ given_date: '2026-01-01T00:00:00Z',
74
+ valid_till: null,
75
+ method: 'WEB',
76
+ data_elements: [],
77
+ link_reason: null,
78
+ product_uuid: 'prod-1',
79
+ product_name: 'Acme Loyalty',
80
+ product_description: null,
81
+ nominator: null,
82
+ ...overrides,
83
+ });
84
+
85
+ const renderModal = (record: ConsentModalRecord) =>
86
+ render(
87
+ <NavigationProvider>
88
+ <ModifyConsentModal
89
+ isOpen
90
+ onClose={vi.fn()}
91
+ selectedRecord={record}
92
+ isLoading={false}
93
+ onManageConsent={vi.fn()}
94
+ />
95
+ </NavigationProvider>,
96
+ );
97
+
98
+ describe('per-purpose revoke warning message', () => {
99
+ it('shows the custom warning when revoke_warning_message is set on a revoke record', () => {
100
+ renderModal(makeRecord({ revoke_warning_message: CUSTOM_WARNING }));
101
+
102
+ expect(screen.getByText(CUSTOM_WARNING)).toBeInTheDocument();
103
+ // The localized default must be suppressed when a custom message exists.
104
+ expect(screen.queryByText(DEFAULT_WARNING)).not.toBeInTheDocument();
105
+ // Button label is untouched — still the default revoke copy.
106
+ expect(
107
+ screen.getByRole('button', { name: 'Revoke Consent' }),
108
+ ).toBeInTheDocument();
109
+ });
110
+
111
+ it('shows the localized default warning when revoke_warning_message is null', () => {
112
+ renderModal(makeRecord({ revoke_warning_message: null }));
113
+
114
+ expect(screen.getByText(DEFAULT_WARNING)).toBeInTheDocument();
115
+ expect(
116
+ screen.getByRole('button', { name: 'Revoke Consent' }),
117
+ ).toBeInTheDocument();
118
+ });
119
+
120
+ it('shows the localized default warning when revoke_warning_message is an empty string', () => {
121
+ renderModal(makeRecord({ revoke_warning_message: '' }));
122
+
123
+ expect(screen.getByText(DEFAULT_WARNING)).toBeInTheDocument();
124
+ });
125
+
126
+ it('shows the localized default warning when revoke_warning_message is absent (older backends)', () => {
127
+ renderModal(makeRecord()); // no `revoke_warning_message` field
128
+
129
+ expect(screen.getByText(DEFAULT_WARNING)).toBeInTheDocument();
130
+ expect(
131
+ screen.getByRole('button', { name: 'Revoke Consent' }),
132
+ ).toBeInTheDocument();
133
+ });
134
+
135
+ it('does not apply the custom revoke warning to the regrant branch', () => {
136
+ // A withdrawn record uses the regrant action; the per-purpose revoke
137
+ // message must never leak into the regrant warning.
138
+ renderModal(
139
+ makeRecord({
140
+ status: ConsentStatusEnum.WITHDRAWN,
141
+ revoke_warning_message: CUSTOM_WARNING,
142
+ }),
143
+ );
144
+
145
+ expect(screen.getByText('Regranting will restore this consent.')).toBeInTheDocument();
146
+ expect(screen.queryByText(CUSTOM_WARNING)).not.toBeInTheDocument();
147
+ expect(
148
+ screen.getByRole('button', { name: 'Regrant Consent' }),
149
+ ).toBeInTheDocument();
150
+ });
151
+ });