@redacto.io/consent-sdk-react 10.0.0-beta.1 → 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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +109 -0
- package/README.md +78 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/index.d.mts +32 -4
- package/dist/index.d.ts +32 -4
- package/dist/index.js +340 -230
- package/dist/index.mjs +339 -231
- package/dist/privacy-center.d.mts +55 -12
- package/dist/privacy-center.d.ts +55 -12
- package/dist/privacy-center.js +848 -950
- package/dist/privacy-center.mjs +698 -802
- package/package.json +1 -1
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
- package/src/RedactoNoticeAssisted/api/index.ts +7 -1
- package/src/RedactoNoticeAssisted/api/types.ts +2 -0
- package/src/RedactoNoticeAssisted/i18n.ts +0 -55
- package/src/RedactoNoticeAssisted/types.ts +2 -0
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +74 -33
- package/src/RedactoNoticeConsent/api/index.ts +88 -175
- package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
- package/src/RedactoNoticeConsent/api/types.ts +58 -43
- package/src/RedactoNoticeConsent/language-codes.ts +0 -2
- package/src/RedactoNoticeConsent/types.ts +28 -2
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +85 -5
- package/src/RedactoNoticeConsentInline/api/index.ts +10 -1
- package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
- package/src/RedactoNoticeConsentInline/api/types.ts +2 -0
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
- package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
- package/src/RedactoPrivacyCenter/api/client.ts +122 -25
- package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +47 -45
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
- package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +25 -13
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -6
- package/src/RedactoPrivacyCenter/components/constant.ts +3 -3
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
- package/src/RedactoPrivacyCenter/context/types.ts +38 -0
- package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
- package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +25 -1
- package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/en/translation.json +4 -3
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/or/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/te/translation.json +3 -3
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +3 -3
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +7 -2
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
- package/src/shared/notice-consent-status.ts +190 -0
- package/src/shared/sandbox.ts +6 -0
- package/tests/Form.test.tsx +142 -5
- package/tests/RevokeWarningMessage.test.tsx +151 -0
- package/tests/consentClient.test.ts +46 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -340
|
@@ -2744,7 +2744,10 @@ export const injectPCStyles = () => {
|
|
|
2744
2744
|
border-radius: 9999px;
|
|
2745
2745
|
}
|
|
2746
2746
|
|
|
2747
|
-
|
|
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 {
|
|
2748
2751
|
display: flex;
|
|
2749
2752
|
align-items: center;
|
|
2750
2753
|
justify-content: center;
|
|
@@ -2766,7 +2769,8 @@ export const injectPCStyles = () => {
|
|
|
2766
2769
|
height: 14px;
|
|
2767
2770
|
}
|
|
2768
2771
|
|
|
2769
|
-
.pc-notification-bell-btn:hover
|
|
2772
|
+
.pc-notification-bell-btn:hover,
|
|
2773
|
+
.pc-navbar-avatar-btn:hover {
|
|
2770
2774
|
background-color: var(--pc-bg-hover);
|
|
2771
2775
|
border-color: var(--pc-border-color-hover);
|
|
2772
2776
|
}
|
|
@@ -2829,6 +2833,7 @@ export const injectPCStyles = () => {
|
|
|
2829
2833
|
/* Keyboard focus rings for Notification Hub interactive controls */
|
|
2830
2834
|
.pc-notification-cta:focus-visible,
|
|
2831
2835
|
.pc-notification-bell-btn:focus-visible:not(:disabled),
|
|
2836
|
+
.pc-navbar-avatar-btn:focus-visible:not(:disabled),
|
|
2832
2837
|
.pc-notification-filter-tab:focus-visible:not(:disabled),
|
|
2833
2838
|
.pc-notification-pagination-btn:focus-visible:not(:disabled),
|
|
2834
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
|
|
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
|
|
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={
|
|
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={
|
|
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";
|
package/tests/Form.test.tsx
CHANGED
|
@@ -524,12 +524,14 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
524
524
|
expect(submitButton.disabled).toBe(true);
|
|
525
525
|
});
|
|
526
526
|
|
|
527
|
-
it('
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
527
|
+
it('runs the picker overlay against the legacy endpoint when no ledgerBaseUrl is configured', async () => {
|
|
528
|
+
// `getUserConsents` serves the same grouped payload from either backend
|
|
529
|
+
// (ledger `/public/...` or Python `/dsar/privacy-center/...`), so the
|
|
530
|
+
// overlay must run in both modes — Python's form/data no longer emits
|
|
531
|
+
// product groups, and skipping the read leaves the picker empty.
|
|
531
532
|
baseUrlStub.ledgerBaseUrl = undefined;
|
|
532
533
|
setupSession();
|
|
534
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
533
535
|
getFormData.mockResolvedValue(
|
|
534
536
|
makeFormData({
|
|
535
537
|
direct_groups: [
|
|
@@ -545,12 +547,12 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
545
547
|
|
|
546
548
|
render(<Form onCreateRequest={vi.fn()} />);
|
|
547
549
|
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
550
|
+
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
548
551
|
|
|
549
552
|
pickRequestType(RequestTypeEnum.ERASURE);
|
|
550
553
|
await waitFor(() => {
|
|
551
554
|
expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
|
|
552
555
|
});
|
|
553
|
-
expect(fetchUserConsents).not.toHaveBeenCalled();
|
|
554
556
|
});
|
|
555
557
|
|
|
556
558
|
it('runs the ledger picker overlay when ledgerBaseUrl is configured', async () => {
|
|
@@ -565,6 +567,141 @@ describe('Form (P2-SDK-3)', () => {
|
|
|
565
567
|
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
566
568
|
});
|
|
567
569
|
|
|
570
|
+
it('keeps the form/data groups when user-consents comes back with no groups', async () => {
|
|
571
|
+
// A successful-but-empty read must never overwrite groups form/data did
|
|
572
|
+
// supply — that turned a populated picker into "No products available".
|
|
573
|
+
setupSession();
|
|
574
|
+
fetchUserConsents.mockResolvedValue({ direct: [], nominated: [] });
|
|
575
|
+
getFormData.mockResolvedValue(
|
|
576
|
+
makeFormData({
|
|
577
|
+
direct_groups: [
|
|
578
|
+
makeGroup(),
|
|
579
|
+
makeGroup({
|
|
580
|
+
product_uuid: 'prod-direct-2',
|
|
581
|
+
product_name: 'Acme Rewards',
|
|
582
|
+
purposes: [basePurpose('purp-2', 'Analytics')],
|
|
583
|
+
}),
|
|
584
|
+
],
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
589
|
+
await waitFor(() => expect(fetchUserConsents).toHaveBeenCalled());
|
|
590
|
+
|
|
591
|
+
pickRequestType(RequestTypeEnum.ERASURE);
|
|
592
|
+
await waitFor(() => {
|
|
593
|
+
expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
|
|
594
|
+
});
|
|
595
|
+
expect(screen.getByText('Acme Rewards')).toBeInTheDocument();
|
|
596
|
+
expect(screen.queryByText('No products available')).not.toBeInTheDocument();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('falls back to the legacy flat purposes list when the backend exposes no product groups', async () => {
|
|
600
|
+
// Pre-grouping backends return purposes without any product grouping.
|
|
601
|
+
// Those consents must stay reachable under one synthetic group instead
|
|
602
|
+
// of the picker claiming the principal has none.
|
|
603
|
+
setupSession();
|
|
604
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
605
|
+
getFormData.mockResolvedValue(
|
|
606
|
+
makeFormData({
|
|
607
|
+
direct_groups: [],
|
|
608
|
+
nominated_groups: [],
|
|
609
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
614
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
615
|
+
|
|
616
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
617
|
+
|
|
618
|
+
await waitFor(() => {
|
|
619
|
+
expect(screen.getByText('Your data')).toBeInTheDocument();
|
|
620
|
+
});
|
|
621
|
+
expect(screen.getByText('Marketing')).toBeInTheDocument();
|
|
622
|
+
expect(screen.queryByText('No products available')).not.toBeInTheDocument();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('keeps the empty state when the flat purposes carry no data elements', async () => {
|
|
626
|
+
// Nothing selectable survives `buildGroupPurposes`, so the synthetic group
|
|
627
|
+
// would render an unusable panel — the empty state is the honest answer.
|
|
628
|
+
setupSession();
|
|
629
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
630
|
+
getFormData.mockResolvedValue(
|
|
631
|
+
makeFormData({
|
|
632
|
+
direct_groups: [],
|
|
633
|
+
nominated_groups: [],
|
|
634
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing', []) }],
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
639
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
640
|
+
|
|
641
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
642
|
+
|
|
643
|
+
await waitFor(() => {
|
|
644
|
+
expect(screen.getByText('No products available')).toBeInTheDocument();
|
|
645
|
+
});
|
|
646
|
+
expect(screen.queryByText('Your data')).not.toBeInTheDocument();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('submits the legacy flat fallback as an org-level case without a product anchor', async () => {
|
|
650
|
+
// The synthetic group has no server-side product, so the case must go up
|
|
651
|
+
// in the pre-grouping org-level shape rather than carrying a made-up
|
|
652
|
+
// `product_uuid` the backend cannot resolve.
|
|
653
|
+
setupSession();
|
|
654
|
+
fetchUserConsents.mockResolvedValue(null);
|
|
655
|
+
getFormData.mockResolvedValue(
|
|
656
|
+
makeFormData({
|
|
657
|
+
direct_groups: [],
|
|
658
|
+
nominated_groups: [],
|
|
659
|
+
purposes: [{ purpose: basePurpose('purp-flat', 'Marketing') }],
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
createCaseRequest.mockResolvedValueOnce({
|
|
663
|
+
case_id: 'CASE-FLAT',
|
|
664
|
+
uuid: 'uuid-flat',
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
render(<Form onCreateRequest={vi.fn()} />);
|
|
668
|
+
await waitFor(() => expect(getFormData).toHaveBeenCalled());
|
|
669
|
+
|
|
670
|
+
pickRequestType(RequestTypeEnum.ACCESS);
|
|
671
|
+
|
|
672
|
+
await waitFor(() => {
|
|
673
|
+
expect(screen.getByText('Marketing')).toBeInTheDocument();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const marketingCheckbox = screen
|
|
677
|
+
.getByText('Marketing')
|
|
678
|
+
.closest('label')
|
|
679
|
+
?.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
|
680
|
+
expect(marketingCheckbox).toBeTruthy();
|
|
681
|
+
fireEvent.click(marketingCheckbox!);
|
|
682
|
+
|
|
683
|
+
const confirmCheckbox = (
|
|
684
|
+
screen.getAllByRole('checkbox') as HTMLInputElement[]
|
|
685
|
+
).find((c) => c.closest('label')?.textContent?.includes('I confirm'));
|
|
686
|
+
expect(confirmCheckbox).toBeTruthy();
|
|
687
|
+
fireEvent.click(confirmCheckbox!);
|
|
688
|
+
|
|
689
|
+
const submitButton = screen.getByRole('button', { name: /^submit$/i });
|
|
690
|
+
expect(submitButton).not.toBeDisabled();
|
|
691
|
+
await act(async () => {
|
|
692
|
+
fireEvent.click(submitButton);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
await waitFor(() => expect(createCaseRequest).toHaveBeenCalledTimes(1));
|
|
696
|
+
|
|
697
|
+
const payload = createCaseRequest.mock.calls[0][2];
|
|
698
|
+
expect('product_uuid' in payload).toBe(false);
|
|
699
|
+
expect('data_principal_uuid' in payload).toBe(false);
|
|
700
|
+
expect(payload.request_details.purposes).toEqual([
|
|
701
|
+
{ purpose_uuid: 'purp-flat', data_element_uuids: ['purp-flat-de1'] },
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
568
705
|
it('renders the product step for DSAR types when direct_groups is present', async () => {
|
|
569
706
|
setupSession();
|
|
570
707
|
const direct = makeGroup({
|