@redacto.io/consent-sdk-react 10.0.0-beta.0 → 10.0.0-beta.11

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 (89) hide show
  1. package/.turbo/turbo-build.log +22 -25
  2. package/CHANGELOG.md +123 -0
  3. package/README.md +78 -0
  4. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  5. package/dist/index.d.mts +43 -8
  6. package/dist/index.d.ts +43 -8
  7. package/dist/index.js +345 -237
  8. package/dist/index.mjs +344 -238
  9. package/dist/privacy-center.d.mts +113 -37
  10. package/dist/privacy-center.d.ts +113 -37
  11. package/dist/privacy-center.js +2317 -1910
  12. package/dist/privacy-center.mjs +2245 -1835
  13. package/package.json +1 -1
  14. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
  15. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
  16. package/src/RedactoNoticeAssisted/api/index.ts +7 -1
  17. package/src/RedactoNoticeAssisted/api/types.ts +2 -0
  18. package/src/RedactoNoticeAssisted/i18n.ts +0 -55
  19. package/src/RedactoNoticeAssisted/types.ts +2 -0
  20. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
  21. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +75 -33
  22. package/src/RedactoNoticeConsent/api/index.ts +90 -177
  23. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  24. package/src/RedactoNoticeConsent/api/types.ts +60 -44
  25. package/src/RedactoNoticeConsent/language-codes.ts +0 -2
  26. package/src/RedactoNoticeConsent/types.ts +29 -3
  27. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +86 -5
  28. package/src/RedactoNoticeConsentInline/api/index.ts +14 -5
  29. package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
  30. package/src/RedactoNoticeConsentInline/api/types.ts +4 -1
  31. package/src/RedactoNoticeConsentInline/types.ts +1 -1
  32. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
  33. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +38 -3
  34. package/src/RedactoPrivacyCenter/api/actions.ts +13 -5
  35. package/src/RedactoPrivacyCenter/api/client.ts +216 -39
  36. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  37. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
  38. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +107 -76
  39. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  40. package/src/RedactoPrivacyCenter/components/Form.tsx +53 -24
  41. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
  42. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +95 -0
  43. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +195 -0
  44. package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
  45. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  46. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  47. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
  48. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
  49. package/src/RedactoPrivacyCenter/context/types.ts +39 -1
  50. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  51. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
  52. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
  53. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  54. package/src/RedactoPrivacyCenter/lib/types.ts +78 -2
  55. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  56. package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
  57. package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
  58. package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
  59. package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
  60. package/src/RedactoPrivacyCenter/locales/en/translation.json +8 -1
  61. package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
  62. package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
  63. package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
  64. package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
  65. package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
  66. package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
  67. package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
  68. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
  69. package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
  70. package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
  71. package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
  72. package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
  73. package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
  74. package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
  75. package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
  76. package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
  77. package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
  78. package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
  79. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +112 -2
  80. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
  81. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
  82. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
  83. package/src/shared/notice-consent-status.ts +190 -0
  84. package/src/shared/sandbox.ts +6 -0
  85. package/tests/Form.test.tsx +190 -7
  86. package/tests/RevokeWarningMessage.test.tsx +151 -0
  87. package/tests/consentClient.test.ts +217 -0
  88. package/tests/manageConsentServer.test.ts +38 -0
  89. package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -334
@@ -664,6 +664,111 @@ export const injectPCStyles = () => {
664
664
  color: var(--pc-text-secondary);
665
665
  line-height: 1.25;
666
666
  }
667
+ .pc-select-user {
668
+ display: flex;
669
+ flex-direction: column;
670
+ justify-content: center;
671
+ gap: var(--pc-spacing-xl);
672
+ width: 100%;
673
+ max-width: 460px;
674
+ margin: 0 auto;
675
+ min-height: min(62vh, 540px);
676
+ padding-top: var(--pc-spacing-2xl);
677
+ padding-bottom: var(--pc-spacing-2xl);
678
+ }
679
+ .pc-select-user__header {
680
+ display: flex;
681
+ flex-direction: column;
682
+ align-items: center;
683
+ text-align: center;
684
+ gap: var(--pc-spacing-sm);
685
+ }
686
+ .pc-select-user__header > * {
687
+ margin: 0;
688
+ }
689
+ .pc-select-user__list {
690
+ display: flex;
691
+ flex-direction: column;
692
+ gap: 0.625rem;
693
+ }
694
+ .pc-select-user__item {
695
+ display: flex;
696
+ align-items: center;
697
+ justify-content: space-between;
698
+ gap: 0.75rem;
699
+ width: 100%;
700
+ box-sizing: border-box;
701
+ padding: 0.75rem 1rem;
702
+ border: 1px solid var(--pc-border-color);
703
+ border-radius: 0.625rem;
704
+ background-color: var(--pc-bg-primary);
705
+ color: var(--pc-text-primary);
706
+ cursor: pointer;
707
+ text-align: left;
708
+ appearance: none;
709
+ -webkit-appearance: none;
710
+ font-family: inherit;
711
+ transition: border-color 0.2s ease, background-color 0.2s ease;
712
+ }
713
+ .pc-select-user__item:hover:not(:disabled) {
714
+ border-color: var(--pc-primary);
715
+ background-color: color-mix(in srgb, var(--pc-primary) 6%, var(--pc-bg-primary));
716
+ }
717
+ .pc-select-user__item:disabled {
718
+ cursor: default;
719
+ opacity: 0.7;
720
+ }
721
+ .pc-select-user__item:focus-visible {
722
+ outline: 2px solid var(--pc-border-color-focus);
723
+ outline-offset: 2px;
724
+ }
725
+ .pc-select-user__item-text {
726
+ display: flex;
727
+ flex-direction: column;
728
+ align-items: flex-start;
729
+ gap: 0.125rem;
730
+ min-width: 0;
731
+ }
732
+ .pc-select-user__item-primary {
733
+ font-size: 0.875rem;
734
+ font-weight: 600;
735
+ line-height: 1.25;
736
+ overflow: hidden;
737
+ text-overflow: ellipsis;
738
+ white-space: nowrap;
739
+ max-width: 100%;
740
+ }
741
+ .pc-select-user__item-secondary {
742
+ font-size: 0.75rem;
743
+ color: var(--pc-text-secondary);
744
+ line-height: 1.25;
745
+ overflow: hidden;
746
+ text-overflow: ellipsis;
747
+ white-space: nowrap;
748
+ max-width: 100%;
749
+ }
750
+ .pc-select-user__item-status {
751
+ font-size: 0.75rem;
752
+ font-weight: 600;
753
+ color: var(--pc-primary);
754
+ flex-shrink: 0;
755
+ }
756
+ .pc-select-user__item-chevron {
757
+ color: var(--pc-primary);
758
+ flex-shrink: 0;
759
+ }
760
+ .pc-select-user__error {
761
+ margin: 0.75rem 0 0;
762
+ font-size: 0.8125rem;
763
+ color: var(--pc-error);
764
+ text-align: center;
765
+ }
766
+ .pc-select-user-loading {
767
+ display: flex;
768
+ align-items: center;
769
+ justify-content: center;
770
+ min-height: 60vh;
771
+ }
667
772
  .pc-product-purpose-panel {
668
773
  display: flex;
669
774
  flex-direction: column;
@@ -2639,7 +2744,10 @@ export const injectPCStyles = () => {
2639
2744
  border-radius: 9999px;
2640
2745
  }
2641
2746
 
2642
- .pc-notification-bell-btn {
2747
+ /* Circular navbar controls: the bell and the org-logo menu button. Same size
2748
+ token, ring and hover, so the topbar cluster reads as one set of controls. */
2749
+ .pc-notification-bell-btn,
2750
+ .pc-navbar-avatar-btn {
2643
2751
  display: flex;
2644
2752
  align-items: center;
2645
2753
  justify-content: center;
@@ -2661,7 +2769,8 @@ export const injectPCStyles = () => {
2661
2769
  height: 14px;
2662
2770
  }
2663
2771
 
2664
- .pc-notification-bell-btn:hover {
2772
+ .pc-notification-bell-btn:hover,
2773
+ .pc-navbar-avatar-btn:hover {
2665
2774
  background-color: var(--pc-bg-hover);
2666
2775
  border-color: var(--pc-border-color-hover);
2667
2776
  }
@@ -2724,6 +2833,7 @@ export const injectPCStyles = () => {
2724
2833
  /* Keyboard focus rings for Notification Hub interactive controls */
2725
2834
  .pc-notification-cta:focus-visible,
2726
2835
  .pc-notification-bell-btn:focus-visible:not(:disabled),
2836
+ .pc-navbar-avatar-btn:focus-visible:not(:disabled),
2727
2837
  .pc-notification-filter-tab:focus-visible:not(:disabled),
2728
2838
  .pc-notification-pagination-btn:focus-visible:not(:disabled),
2729
2839
  .pc-notification-link-btn:focus-visible:not(:disabled),
@@ -669,6 +669,23 @@ export const pcStyles = {
669
669
  height: '48px',
670
670
  fontSize: getVar('--pc-font-size-base'),
671
671
  },
672
+ // Image avatars (org logos) must NOT sit on the brand-colour fill `avatar` sets:
673
+ // --pc-info resolves to the org's own primary colour, so a brand-coloured mark on a
674
+ // transparent background would be invisible against it. Neutral surface + hairline
675
+ // border instead. border-box keeps the rendered box at the exact avatarSm/Md/Lg size.
676
+ avatarImage: {
677
+ backgroundColor: getVar('--pc-bg-shell'),
678
+ border: `1px solid ${getVar('--pc-border-color')}`,
679
+ boxSizing: 'border-box',
680
+ objectFit: 'contain',
681
+ padding: '3px',
682
+ },
683
+ // For an image avatar inside a control that already draws the ring and surface
684
+ // (the navbar avatar button) — otherwise the two rings nest as concentric circles.
685
+ avatarImageBare: {
686
+ backgroundColor: 'transparent',
687
+ border: 'none',
688
+ },
672
689
 
673
690
  // Empty state styles
674
691
  empty: {
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { PCAvatar } from './PCAvatar';
4
+
5
+ /**
6
+ * Regression: PCAvatar applied a single style object to BOTH branches, so an image
7
+ * avatar (the org logo) was painted onto a disc filled with var(--pc-info) — which
8
+ * applyBrandingTheme maps to the org's own primary colour. A brand-coloured mark on a
9
+ * transparent background therefore rendered as a featureless brand-coloured circle.
10
+ */
11
+
12
+ const LOGO_SRC = 'https://cdn.example.com/org-icon.svg';
13
+
14
+ afterEach(cleanup);
15
+
16
+ describe('PCAvatar', () => {
17
+ it('does not paint the brand-colour fill behind a logo image', () => {
18
+ render(<PCAvatar src={LOGO_SRC} name="Acme Corp" color="info" />);
19
+
20
+ const image = screen.getByRole('img');
21
+ expect(image.style.backgroundColor).not.toBe('var(--pc-info)');
22
+ expect(image.style.backgroundColor).toBe('var(--pc-bg-shell)');
23
+ });
24
+
25
+ it('contains the logo instead of stretching it, and keeps the box at its nominal size', () => {
26
+ render(<PCAvatar src={LOGO_SRC} name="Acme Corp" size="sm" color="info" />);
27
+
28
+ const image = screen.getByRole('img');
29
+ expect(image.style.objectFit).toBe('contain');
30
+ expect(image.style.boxSizing).toBe('border-box');
31
+ expect(image.style.width).toBe('24px');
32
+ expect(image.style.height).toBe('24px');
33
+ });
34
+
35
+ it('falls back to initials when the logo fails to load', () => {
36
+ render(<PCAvatar src={LOGO_SRC} name="Acme Corp" color="info" />);
37
+
38
+ fireEvent.error(screen.getByRole('img'));
39
+
40
+ expect(screen.queryByRole('img')).toBeNull();
41
+ expect(screen.getByText('AC')).toBeTruthy();
42
+ });
43
+
44
+ it('still fills initials avatars with the requested colour', () => {
45
+ render(<PCAvatar name="Acme Corp" color="info" />);
46
+
47
+ expect(screen.getByText('AC').style.backgroundColor).toBe('var(--pc-info)');
48
+ });
49
+
50
+ it('keeps its tile and ring by default (standalone brand mark)', () => {
51
+ render(<PCAvatar src={LOGO_SRC} name="Acme Corp" color="info" />);
52
+
53
+ const image = screen.getByRole('img');
54
+ expect(image.style.border).toContain('var(--pc-border-color)');
55
+ expect(image.style.backgroundColor).toBe('var(--pc-bg-shell)');
56
+ });
57
+
58
+ it('drops its tile and ring when framed={false}, so it cannot nest inside a bordered control', () => {
59
+ render(<PCAvatar src={LOGO_SRC} name="Acme Corp" color="info" framed={false} />);
60
+
61
+ const image = screen.getByRole('img');
62
+ expect(image.style.border).not.toContain('--pc-border-color');
63
+ expect(image.style.backgroundColor).toBe('transparent');
64
+ // Still contained — framed only controls the chrome, not the fit.
65
+ expect(image.style.objectFit).toBe('contain');
66
+ });
67
+
68
+ it('does not strip the initials fill when framed={false}', () => {
69
+ render(<PCAvatar name="Acme Corp" color="info" framed={false} />);
70
+
71
+ expect(screen.getByText('AC').style.backgroundColor).toBe('var(--pc-info)');
72
+ });
73
+
74
+ it('lets a caller override the style on both branches', () => {
75
+ const { rerender } = render(
76
+ <PCAvatar src={LOGO_SRC} name="Acme Corp" style={{ backgroundColor: 'red' }} />,
77
+ );
78
+ expect(screen.getByRole('img').style.backgroundColor).toBe('red');
79
+
80
+ rerender(<PCAvatar name="Acme Corp" style={{ backgroundColor: 'red' }} />);
81
+ expect(screen.getByText('AC').style.backgroundColor).toBe('red');
82
+ });
83
+ });
@@ -1,14 +1,17 @@
1
- import { type CSSProperties } from 'react';
1
+ import { useEffect, useState, type CSSProperties } from 'react';
2
2
  import { pcStyles } from '../styles/pcStyles';
3
3
 
4
- export interface PCAvatarProps {
4
+ export type PCAvatarProps = Readonly<{
5
5
  name?: string;
6
6
  src?: string;
7
7
  className?: string;
8
8
  style?: CSSProperties;
9
9
  size?: 'sm' | 'md' | 'lg';
10
10
  color?: 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info';
11
- }
11
+ // Image avatars draw their own surface + hairline ring. Set false when a parent
12
+ // control already draws them, so the two don't nest as concentric rings.
13
+ framed?: boolean;
14
+ }>;
12
15
 
13
16
  function getInitials(name: string): string {
14
17
  return name
@@ -40,29 +43,50 @@ export function PCAvatar({
40
43
  style = {},
41
44
  size = 'md',
42
45
  color = 'primary',
46
+ framed = true,
43
47
  }: PCAvatarProps) {
44
- const avatarStyle: CSSProperties = {
48
+ const [failed, setFailed] = useState(false);
49
+
50
+ // A new src deserves a fresh attempt — otherwise switching org/profile strands
51
+ // the avatar on the previous image's failure.
52
+ useEffect(() => setFailed(false), [src]);
53
+
54
+ const baseStyle: CSSProperties = {
45
55
  ...pcStyles.avatar,
46
56
  ...(size === 'sm' && pcStyles.avatarSm),
47
57
  ...(size === 'md' && pcStyles.avatarMd),
48
58
  ...(size === 'lg' && pcStyles.avatarLg),
49
- backgroundColor: getColor(color),
50
- ...style,
51
59
  };
52
60
 
53
- if (src) {
61
+ if (src && !failed) {
62
+ // avatarImage overrides the brand-colour fill baked into pcStyles.avatar; a logo
63
+ // is usually a brand-coloured mark on transparency and would vanish against it.
64
+ const imageStyle: CSSProperties = {
65
+ ...baseStyle,
66
+ ...pcStyles.avatarImage,
67
+ ...(!framed && pcStyles.avatarImageBare),
68
+ ...style,
69
+ };
70
+
54
71
  return (
55
72
  <img
56
73
  src={src}
57
74
  alt={name}
58
75
  className={className}
59
- style={avatarStyle}
76
+ style={imageStyle}
77
+ onError={() => setFailed(true)}
60
78
  />
61
79
  );
62
80
  }
63
81
 
82
+ const initialsStyle: CSSProperties = {
83
+ ...baseStyle,
84
+ backgroundColor: getColor(color),
85
+ ...style,
86
+ };
87
+
64
88
  return (
65
- <div className={className} style={avatarStyle}>
89
+ <div className={className} style={initialsStyle}>
66
90
  {name ? getInitials(name) : '?'}
67
91
  </div>
68
92
  );
@@ -0,0 +1,190 @@
1
+ import { jwtDecode } from "jwt-decode";
2
+
3
+ /**
4
+ * Shared Go-ledger notice `check-consent` read for the consent-sdk-react
5
+ * package.
6
+ *
7
+ * Post-cutover, consent records live in the Go ledger, so the Python
8
+ * `get-notice` endpoint no longer knows whether a principal has already
9
+ * consented (it used to answer that with a 409 `CONSENT_ALREADY_PROVIDED`).
10
+ * Both the banner (`RedactoNoticeConsent`) and the embedded widget
11
+ * (`RedactoNoticeConsentInline`) ask the ledger directly instead — this module
12
+ * is the single implementation they share.
13
+ *
14
+ * Every failure mode (no ledger configured, undecodable token, non-OK
15
+ * response, network/abort/parse error) degrades to `null` so callers can fall
16
+ * back to the legacy Python-driven flow.
17
+ */
18
+
19
+ type NoticeConsentJwtPayload = {
20
+ organisation_uuid: string;
21
+ workspace_uuid: string;
22
+ };
23
+
24
+ // Mirrors the Go ledger's `NoticeConsentStatusResponse`. Used to overlay the
25
+ // prior-consent / reconsent state from the ledger, since Python's
26
+ // notice-validation overlay is empty after the consent cutover.
27
+ export type NoticeConsentStatusDataElement = {
28
+ uuid: string;
29
+ name: string;
30
+ selected: boolean;
31
+ };
32
+
33
+ export type NoticeConsentStatusPurpose = {
34
+ uuid: string;
35
+ name: string;
36
+ selected: boolean;
37
+ status: string; // ACTIVE | EXPIRED | WITHDRAW | DECLINED | INACTIVE
38
+ expiry_datetime?: string | null;
39
+ data_elements: NoticeConsentStatusDataElement[];
40
+ };
41
+
42
+ export type NoticeConsentStatusResponse = {
43
+ consented: boolean;
44
+ all_mandatory_active: boolean;
45
+ purposes: NoticeConsentStatusPurpose[];
46
+ };
47
+
48
+ export type FetchNoticeConsentStatusParams = {
49
+ accessToken: string;
50
+ ledgerBaseUrl?: string;
51
+ noticeUuid: string;
52
+ signal?: AbortSignal;
53
+ };
54
+
55
+ // Same read, but with org/workspace supplied explicitly instead of decoded from
56
+ // the JWT. Used by flows that already hold authoritative org/workspace (props)
57
+ // and whose token may be opaque — e.g. RedactoNoticeAssisted, whose OTP access
58
+ // token carries no org/workspace claims.
59
+ export type FetchNoticeConsentStatusForScopeParams = {
60
+ accessToken: string;
61
+ ledgerBaseUrl?: string;
62
+ organisationUuid: string;
63
+ workspaceUuid: string;
64
+ noticeUuid: string;
65
+ signal?: AbortSignal;
66
+ };
67
+
68
+ // Decode the JWT to resolve the org/workspace the ledger path is scoped to.
69
+ // Returns null on any decode failure so the caller degrades gracefully.
70
+ const decodeNoticeConsentToken = (
71
+ token: string
72
+ ): NoticeConsentJwtPayload | null => {
73
+ try {
74
+ return jwtDecode(token) as NoticeConsentJwtPayload;
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Fetch the principal's current consent status for a notice from the Go
82
+ * ledger (`check-consent`), with org/workspace supplied explicitly. The
83
+ * principal resolves from the bearer token server-side, so the body is empty.
84
+ * Returns null on any failure so the caller can fall back to the Python-driven
85
+ * flow (graceful degradation). This holds the single network implementation;
86
+ * {@link fetchNoticeConsentStatus} decodes the JWT and delegates here.
87
+ */
88
+ export const fetchNoticeConsentStatusForScope = async ({
89
+ accessToken,
90
+ ledgerBaseUrl,
91
+ organisationUuid,
92
+ workspaceUuid,
93
+ noticeUuid,
94
+ signal,
95
+ }: FetchNoticeConsentStatusForScopeParams): Promise<NoticeConsentStatusResponse | null> => {
96
+ if (
97
+ !accessToken ||
98
+ !ledgerBaseUrl ||
99
+ !organisationUuid ||
100
+ !workspaceUuid ||
101
+ !noticeUuid
102
+ ) {
103
+ return null;
104
+ }
105
+
106
+ try {
107
+ const response = await fetch(
108
+ `${ledgerBaseUrl}/public/organisations/${organisationUuid}/workspaces/${workspaceUuid}/notices/${noticeUuid}/check-consent`,
109
+ {
110
+ method: "POST",
111
+ headers: {
112
+ Authorization: `Bearer ${accessToken}`,
113
+ "Content-Type": "application/json",
114
+ },
115
+ body: "{}",
116
+ signal,
117
+ }
118
+ );
119
+
120
+ if (!response.ok) {
121
+ return null;
122
+ }
123
+
124
+ const data = (await response.json()) as
125
+ | NoticeConsentStatusResponse
126
+ | { detail?: NoticeConsentStatusResponse };
127
+ // The ledger returns the response directly; tolerate a {detail} envelope too.
128
+ const body =
129
+ data && typeof data === "object" && "detail" in data && data.detail
130
+ ? data.detail
131
+ : (data as NoticeConsentStatusResponse);
132
+
133
+ if (!body || !Array.isArray(body.purposes)) {
134
+ return null;
135
+ }
136
+ return body;
137
+ } catch {
138
+ // Network / abort / parse failure — degrade to the Python-driven flow.
139
+ return null;
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Fetch the principal's current consent status for a notice from the Go
145
+ * ledger (`check-consent`). The org/workspace are resolved by decoding the
146
+ * JWT. Returns null on any failure so the caller can fall back to the
147
+ * Python-provided overlay (graceful degradation).
148
+ */
149
+ export const fetchNoticeConsentStatus = async ({
150
+ accessToken,
151
+ ledgerBaseUrl,
152
+ noticeUuid,
153
+ signal,
154
+ }: FetchNoticeConsentStatusParams): Promise<NoticeConsentStatusResponse | null> => {
155
+ if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
156
+ return null;
157
+ }
158
+
159
+ const decodedToken = decodeNoticeConsentToken(accessToken);
160
+ if (!decodedToken) {
161
+ return null;
162
+ }
163
+
164
+ const {
165
+ organisation_uuid: ORGANISATION_UUID,
166
+ workspace_uuid: WORKSPACE_UUID,
167
+ } = decodedToken;
168
+ if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
169
+ return null;
170
+ }
171
+
172
+ return fetchNoticeConsentStatusForScope({
173
+ accessToken,
174
+ ledgerBaseUrl,
175
+ organisationUuid: ORGANISATION_UUID,
176
+ workspaceUuid: WORKSPACE_UUID,
177
+ noticeUuid,
178
+ signal,
179
+ });
180
+ };
181
+
182
+ /**
183
+ * Whether the principal has already fully consented to this notice with no
184
+ * reconsent needed — the ledger equivalent of Python's 409
185
+ * `CONSENT_ALREADY_PROVIDED`. Requires an active consent record with every
186
+ * mandatory purpose still ACTIVE.
187
+ */
188
+ export const isNoticeFullyConsented = (
189
+ status: NoticeConsentStatusResponse
190
+ ): boolean => status.consented && status.all_mandatory_active;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * HTTP header carrying the static sandbox token (the credential). Its presence
3
+ * marks a request as `environment=test`. Shared by the Notice-consent and
4
+ * Privacy-Center sandbox paths so the header name is defined once.
5
+ */
6
+ export const SANDBOX_TOKEN_HEADER = "X-Consent-Token";