@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
@@ -1,9 +1,12 @@
1
1
  import { useEffect, useState, type CSSProperties } from 'react';
2
- import { ChevronDown } from 'lucide-react';
2
+ import { Users } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
3
4
  import { PCAvatar, PCPopover } from '../../ui';
4
5
  import { useBranding } from '../../context/BrandingContext';
5
6
  import { useOrganization } from '../../context/OrganizationContext';
6
- import { getVar } from '../../lib/utils';
7
+ import { useProfileSwitch } from '../../context/ProfileSwitchContext';
8
+ import { useAuth } from '../../context/AuthContext';
9
+ import { decodeJwtToken, getVar } from '../../lib/utils';
7
10
  import { usePrivacyCenterExitAction } from './usePrivacyCenterExitAction';
8
11
 
9
12
  type UserMenuProps = {
@@ -30,8 +33,10 @@ function initialsFromEmail(email: string): string {
30
33
  }
31
34
 
32
35
  export function UserMenu({ onBack }: UserMenuProps) {
36
+ const { t } = useTranslation();
33
37
  const branding = useBranding();
34
38
  const { organization } = useOrganization();
39
+ const { accessToken } = useAuth();
35
40
  const [email, setEmail] = useState('');
36
41
 
37
42
  useEffect(() => {
@@ -40,42 +45,63 @@ export function UserMenu({ onBack }: UserMenuProps) {
40
45
 
41
46
  const { handleAction, label: actionLabel, Icon: ActionIcon } =
42
47
  usePrivacyCenterExitAction(onBack);
48
+ const { canSwitch, switchProfile } = useProfileSwitch();
49
+
50
+ // The signed-in principal's org_user_id — unique per profile, unlike the
51
+ // email a shared contact reuses across profiles — so the menu can show which
52
+ // profile is active. Read from the (live) token, which re-scopes on switch.
53
+ const orgUserId =
54
+ (decodeJwtToken(accessToken)?.user_data?.org_user_id as string) || '';
43
55
 
44
56
  const displayName = branding?.brand_name || organization?.name || '';
45
57
  const triggerAvatarName = email ? initialsFromEmail(email) : displayName || '?';
46
58
 
47
- const triggerStyle: CSSProperties = {
48
- display: 'inline-flex',
59
+ // The .pc-navbar-avatar-btn control draws the ring and surface, so the avatar fills
60
+ // it and draws neither (see `framed={false}` below).
61
+ const avatarFillStyle: CSSProperties = {
62
+ width: '100%',
63
+ height: '100%',
64
+ };
65
+
66
+ // Shared style for the popover's action rows (switch profile, back/sign out).
67
+ const menuItemStyle: CSSProperties = {
68
+ display: 'flex',
49
69
  alignItems: 'center',
50
- gap: 6,
51
- padding: '4px 6px 4px 4px',
52
- borderRadius: 9999,
70
+ gap: 10,
71
+ width: '100%',
72
+ padding: '10px 14px',
73
+ border: 'none',
74
+ borderRadius: 8,
53
75
  background: 'transparent',
54
- border: `1px solid ${getVar('--pc-border-color')}`,
55
- color: getVar('--pc-text-secondary'),
76
+ color: getVar('--pc-text-primary'),
56
77
  cursor: 'pointer',
57
- transition: 'background 150ms ease, border-color 150ms ease, box-shadow 150ms ease',
78
+ fontFamily: 'inherit',
79
+ fontSize: 14,
80
+ fontWeight: 500,
81
+ textAlign: 'left',
82
+ transition: 'background 150ms ease',
58
83
  };
59
84
 
60
85
  const trigger = (
61
- <div
62
- style={triggerStyle}
63
- role="presentation"
64
- onMouseEnter={(e) => {
65
- (e.currentTarget as HTMLDivElement).style.boxShadow =
66
- '0 0 0 2px color-mix(in srgb, var(--pc-primary) 18%, transparent)';
67
- }}
68
- onMouseLeave={(e) => {
69
- (e.currentTarget as HTMLDivElement).style.boxShadow = 'none';
70
- }}
71
- >
86
+ <button type="button" aria-label={t('accountMenu')} className="pc-navbar-avatar-btn">
72
87
  {branding?.icon_url ? (
73
- <PCAvatar src={branding.icon_url} name={triggerAvatarName} size="sm" color="info" />
88
+ <PCAvatar
89
+ src={branding.icon_url}
90
+ name={triggerAvatarName}
91
+ size="md"
92
+ color="info"
93
+ framed={false}
94
+ style={avatarFillStyle}
95
+ />
74
96
  ) : (
75
- <PCAvatar name={triggerAvatarName} size="sm" color="info" />
97
+ <PCAvatar
98
+ name={triggerAvatarName}
99
+ size="md"
100
+ color="info"
101
+ style={avatarFillStyle}
102
+ />
76
103
  )}
77
- <ChevronDown size={14} strokeWidth={2} />
78
- </div>
104
+ </button>
79
105
  );
80
106
 
81
107
  const content = (
@@ -132,70 +158,75 @@ export function UserMenu({ onBack }: UserMenuProps) {
132
158
  </div>
133
159
 
134
160
  {/* Identity card */}
135
- {email && (
161
+ {(email || orgUserId) && (
136
162
  <div
137
163
  style={{
138
164
  padding: '12px 16px',
139
165
  display: 'flex',
140
166
  alignItems: 'center',
141
167
  gap: 10,
142
- borderBottom: onBack ? `1px solid ${getVar('--pc-border-color')}` : 'none',
168
+ borderBottom:
169
+ onBack || canSwitch
170
+ ? `1px solid ${getVar('--pc-border-color')}`
171
+ : 'none',
143
172
  }}
144
173
  >
145
- <PCAvatar name={initialsFromEmail(email)} size="sm" color="info" />
146
- <div
147
- title={email}
148
- style={{
149
- fontSize: 13,
150
- fontWeight: 600,
151
- color: getVar('--pc-text-primary'),
152
- lineHeight: 1.3,
153
- overflow: 'hidden',
154
- textOverflow: 'ellipsis',
155
- whiteSpace: 'nowrap',
156
- minWidth: 0,
157
- flex: 1,
158
- }}
159
- >
160
- {email}
174
+ <PCAvatar name={triggerAvatarName} size="sm" color="info" />
175
+ <div className="pc-shell-user-menu-identity">
176
+ {email ? (
177
+ <div title={email} className="pc-shell-user-menu-identity-email">
178
+ {email}
179
+ </div>
180
+ ) : null}
181
+ {orgUserId ? (
182
+ <div
183
+ title={orgUserId}
184
+ className="pc-shell-user-menu-identity-id"
185
+ >
186
+ {t('userIdLabel', { id: orgUserId })}
187
+ </div>
188
+ ) : null}
161
189
  </div>
162
190
  </div>
163
191
  )}
164
192
 
165
- {/* Action row */}
166
- {onBack && (
193
+ {/* Action rows */}
194
+ {(canSwitch || onBack) && (
167
195
  <div style={{ padding: '6px 8px 8px' }}>
168
- <button
169
- type="button"
170
- role="menuitem"
171
- onClick={handleAction}
172
- style={{
173
- display: 'flex',
174
- alignItems: 'center',
175
- gap: 10,
176
- width: '100%',
177
- padding: '10px 14px',
178
- border: 'none',
179
- borderRadius: 8,
180
- background: 'transparent',
181
- color: getVar('--pc-text-primary'),
182
- cursor: 'pointer',
183
- fontFamily: 'inherit',
184
- fontSize: 14,
185
- fontWeight: 500,
186
- textAlign: 'left',
187
- transition: 'background 150ms ease',
188
- }}
189
- onMouseEnter={(e) => {
190
- e.currentTarget.style.background = getVar('--pc-bg-hover');
191
- }}
192
- onMouseLeave={(e) => {
193
- e.currentTarget.style.background = 'transparent';
194
- }}
195
- >
196
- <ActionIcon size={16} strokeWidth={2} />
197
- <span>{actionLabel}</span>
198
- </button>
196
+ {canSwitch && (
197
+ <button
198
+ type="button"
199
+ role="menuitem"
200
+ onClick={switchProfile}
201
+ style={menuItemStyle}
202
+ onMouseEnter={(e) => {
203
+ e.currentTarget.style.background = getVar('--pc-bg-hover');
204
+ }}
205
+ onMouseLeave={(e) => {
206
+ e.currentTarget.style.background = 'transparent';
207
+ }}
208
+ >
209
+ <Users size={16} strokeWidth={2} />
210
+ <span>{t('switchProfile')}</span>
211
+ </button>
212
+ )}
213
+ {onBack && (
214
+ <button
215
+ type="button"
216
+ role="menuitem"
217
+ onClick={handleAction}
218
+ style={menuItemStyle}
219
+ onMouseEnter={(e) => {
220
+ e.currentTarget.style.background = getVar('--pc-bg-hover');
221
+ }}
222
+ onMouseLeave={(e) => {
223
+ e.currentTarget.style.background = 'transparent';
224
+ }}
225
+ >
226
+ <ActionIcon size={16} strokeWidth={2} />
227
+ <span>{actionLabel}</span>
228
+ </button>
229
+ )}
199
230
  </div>
200
231
  )}
201
232
  </div>
@@ -776,6 +776,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
776
776
  productUuid,
777
777
  noticeUuid,
778
778
  dataElements,
779
+ language: langParam,
779
780
  });
780
781
 
781
782
  if (response) {
@@ -798,7 +799,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
798
799
  handleCloseModal();
799
800
  }
800
801
  },
801
- [organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient, selectedRecord]
802
+ [organizationId, workspaceId, contact, fetchData, handleCloseModal, t, authContext, consentClient, selectedRecord, langParam]
802
803
  );
803
804
 
804
805
  const getVar = (varName: string): string => `var(${varName})`;
@@ -10,10 +10,10 @@ import { PiUploadBold } from 'react-icons/pi';
10
10
  import { toast } from '../utils/toast';
11
11
  import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
12
12
  import { PCModal } from '../ui/PCModal';
13
- import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup } from './groupHelpers';
13
+ import { buildGroupDataElements, buildGroupPurposes, consentGroupToFormProductGroup, legacyFlatGroup } from './groupHelpers';
14
14
  import { pcStyles } from '../styles/pcStyles';
15
15
  import { createCaseRequest, fetchUserConsents, getFormData } from '../lib/api';
16
- import { FORM_PICKER_GROUP_LIMIT } from '../lib/constants';
16
+ import { FORM_PICKER_GROUP_LIMIT, LEGACY_PRODUCT_GROUP_UUID } from '../lib/constants';
17
17
  import { ensureValidToken } from '../lib/utils';
18
18
  import { useAuth } from '../context/AuthContext';
19
19
  import { useBaseUrl } from '../context/BaseUrlContext';
@@ -235,12 +235,31 @@ export default function Form({
235
235
 
236
236
  // Union of direct + nominated product groups exposed by the backend.
237
237
  // Drives both the product-cards step and the per-product picker beneath.
238
+ // When the backend exposes no groups at all but did return the legacy flat
239
+ // `purposes` list (pre-grouping Python deployments, and the popup /
240
+ // case-management contexts that cannot read user-consents), fall back to a
241
+ // single synthetic group so the purposes stay reachable instead of the
242
+ // picker claiming the principal has no consents.
238
243
  const allGroups = useMemo<FormProductGroup[]>(() => {
239
244
  if (!data) {
240
245
  return [];
241
246
  }
242
- return [...(data.direct_groups ?? []), ...(data.nominated_groups ?? [])];
243
- }, [data]);
247
+ const groups = [
248
+ ...(data.direct_groups ?? []),
249
+ ...(data.nominated_groups ?? []),
250
+ ];
251
+ if (groups.length > 0) {
252
+ return groups;
253
+ }
254
+ const flatGroup = legacyFlatGroup(data, t('yourData'));
255
+ // `buildGroupPurposes` is what the panel renders from — it drops purposes
256
+ // with no data elements, so a flat list that survives nothing must keep
257
+ // showing the empty state rather than an unusable panel.
258
+ if (buildGroupPurposes(flatGroup).length === 0) {
259
+ return [];
260
+ }
261
+ return [flatGroup];
262
+ }, [data, t]);
244
263
 
245
264
  // TECH-3712 UX: when the DP has exactly one consent group total
246
265
  // (direct + nominated combined), the product-selection step is friction —
@@ -275,16 +294,20 @@ export default function Form({
275
294
  [],
276
295
  );
277
296
 
278
- // Approach A: source the DSAR request-form product picker from the Go
279
- // ledger's user-consents. After the consent cutover, consents live in the
280
- // ledger, so Python's form/data returns no product groups; we overlay the
281
- // ledger's product-grouped consents onto the form data instead. Only the
282
- // end-user privacy-center path is eligible — popup / case-management resolve
283
- // an arbitrary contact, which the JWT-scoped ledger endpoint cannot serve,
284
- // so those keep the Python form/data groups. Returns the form data unchanged
285
- // when ineligible or when the ledger read fails (graceful fallback to
286
- // whatever groups form/data supplied).
287
- const withLedgerPickerGroups = useCallback(
297
+ // Approach A: source the DSAR request-form product picker from user-consents
298
+ // rather than from Python's form/data, which stopped emitting product groups
299
+ // after the consent cutover. `consentClient.getUserConsents` serves the same
300
+ // grouped payload from either backend — the Go ledger's
301
+ // `/public/.../user-consents` when a ledgerBaseUrl is configured, Python's
302
+ // `/dsar/privacy-center/user-consents` otherwise (the same source the
303
+ // Manage Consent view reads in both modes) — so the overlay runs regardless
304
+ // of which backend the SDK points at. Only the end-user privacy-center path
305
+ // is eligible: popup / case-management resolve an arbitrary contact, which
306
+ // the JWT-scoped user-consents endpoint cannot serve, so those keep the
307
+ // form/data groups. Returns the form data unchanged when ineligible, when
308
+ // the read fails, or when it comes back with no groups at all — an empty
309
+ // result must never overwrite groups form/data did supply.
310
+ const withUserConsentPickerGroups = useCallback(
288
311
  async (form: FormData, language?: string): Promise<FormData> => {
289
312
  if (isPopup || isCaseManagementContext || !organizationId || !workspaceId) {
290
313
  return form;
@@ -301,15 +324,15 @@ export default function Form({
301
324
  nominatedLimit: FORM_PICKER_GROUP_LIMIT,
302
325
  language,
303
326
  });
304
- if (!consents) {
327
+ const direct = consents?.direct ?? [];
328
+ const nominated = consents?.nominated ?? [];
329
+ if (direct.length === 0 && nominated.length === 0) {
305
330
  return form;
306
331
  }
307
332
  return {
308
333
  ...form,
309
- direct_groups: (consents.direct ?? []).map(consentGroupToFormProductGroup),
310
- nominated_groups: (consents.nominated ?? []).map(
311
- consentGroupToFormProductGroup,
312
- ),
334
+ direct_groups: direct.map(consentGroupToFormProductGroup),
335
+ nominated_groups: nominated.map(consentGroupToFormProductGroup),
313
336
  };
314
337
  },
315
338
  [
@@ -362,7 +385,7 @@ export default function Form({
362
385
  return;
363
386
  }
364
387
 
365
- setData(await withLedgerPickerGroups(translatedFormData, langValue));
388
+ setData(await withUserConsentPickerGroups(translatedFormData, langValue));
366
389
 
367
390
  if (translatedFormData.grievance_options) {
368
391
  const currentGrievanceSelections = { ...selectedGrievances };
@@ -381,7 +404,7 @@ export default function Form({
381
404
  } finally {
382
405
  setIsTranslating(false);
383
406
  }
384
- }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withLedgerPickerGroups]);
407
+ }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t, withUserConsentPickerGroups]);
385
408
 
386
409
  // Clear product / org-level selections when request type changes.
387
410
  // Per-product state (selectedPurposes, selectedCorrectionElements,
@@ -534,7 +557,7 @@ export default function Form({
534
557
  }
535
558
  }
536
559
 
537
- const pickerData = await withLedgerPickerGroups(
560
+ const pickerData = await withUserConsentPickerGroups(
538
561
  displayData,
539
562
  langParam || undefined,
540
563
  );
@@ -561,7 +584,7 @@ export default function Form({
561
584
  };
562
585
 
563
586
  void initializePrivacyCenterForm();
564
- }, [organizationId, workspaceId, isPopup, authContext, consentClient, withLedgerPickerGroups]);
587
+ }, [organizationId, workspaceId, isPopup, authContext, consentClient, withUserConsentPickerGroups]);
565
588
 
566
589
  /**
567
590
  * Maps translated correction element names back to English names using UUID lookup.
@@ -667,7 +690,13 @@ export default function Form({
667
690
  }
668
691
 
669
692
  const isProductScoped = isProductScopedRequestType(requestType);
670
- const anchorGroup = isProductScoped ? selectedGroup : null;
693
+ // The synthetic legacy-flat group has no server-side product, so it must
694
+ // not anchor the case — those submissions keep the pre-grouping
695
+ // org-level shape (`product_uuid` is optional on the backend).
696
+ const isLegacyFlatSelection =
697
+ selectedGroup?.product_uuid === LEGACY_PRODUCT_GROUP_UUID;
698
+ const anchorGroup =
699
+ isProductScoped && !isLegacyFlatSelection ? selectedGroup : null;
671
700
 
672
701
  const requestDetails: {
673
702
  purposes: UpdatePurpose[];
@@ -32,7 +32,7 @@ export const ModifyConsentModal = ({
32
32
  action: 'revoke' as ConsentAction,
33
33
  buttonLabel: t('revokeConsent'),
34
34
  buttonVariant: 'danger' as const,
35
- warningMessage: t('revokeConsentWarning'),
35
+ warningMessage: selectedRecord.revoke_warning_message || t('revokeConsentWarning'),
36
36
  description: nominatorLabel
37
37
  ? t('aboutToModifyConsentOnBehalf', { nominator: nominatorLabel })
38
38
  : t('aboutToModifyConsent'),
@@ -0,0 +1,95 @@
1
+ import { ChevronRight } from 'lucide-react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { PCText, PCTitle } from '../ui';
4
+ import type { SelectUserProps } from '../lib/types';
5
+
6
+ /**
7
+ * "Choose your profile" picker, shown when the signed-in contact maps to more
8
+ * than one data principal (shared contact). Selecting one re-scopes the session
9
+ * to that principal uuid. Purely presentational — SelectUserGate owns the fetch
10
+ * and the re-scope. Structural styles live in styles/injectStyles.ts
11
+ * (.pc-select-user*).
12
+ */
13
+ export const SelectUser = ({
14
+ candidates,
15
+ onSelect,
16
+ busyOrgUserId,
17
+ error,
18
+ }: SelectUserProps) => {
19
+ const { t } = useTranslation();
20
+
21
+ // A candidate with no uuid can't be selected (it's the value we submit),
22
+ // so drop it rather than render an unclickable row.
23
+ const selectable = candidates.filter((candidate) => candidate.uuid);
24
+
25
+ return (
26
+ <div className="pc-select-user">
27
+ <div className="pc-select-user__header">
28
+ <PCTitle as="h2">{t('chooseYourProfile')}</PCTitle>
29
+ <PCText as="p" size="base" color="secondary" align="center">
30
+ {t('loginIdentifierLinkedToMultipleProfiles')}
31
+ </PCText>
32
+ </div>
33
+
34
+ <div className="pc-select-user__list">
35
+ {selectable.map((candidate) => {
36
+ const principalUuid = candidate.uuid || '';
37
+ const email = candidate.primary_email || '';
38
+ const mobile = candidate.primary_mobile || '';
39
+ const orgUserId = candidate.org_user_id || '';
40
+ // org_user_id is unique per principal (ledger-enforced within the
41
+ // org/workspace), so it leads the row as the reliable way to tell
42
+ // shared-contact profiles apart when the email/mobile are identical.
43
+ // Fall back to a contact when a principal has no org_user_id, so the
44
+ // row still shows something identifiable.
45
+ const primaryLabel = orgUserId
46
+ ? t('userIdLabel', { id: orgUserId })
47
+ : email || mobile || candidate.uuid;
48
+ // Email and mobile drop below the id. Skip any value that already
49
+ // surfaced as the primary label (the fallback case) or duplicates the
50
+ // other contact, so we never echo the same value twice.
51
+ const secondaryLabels = [email, mobile].filter(
52
+ (value, index, all) =>
53
+ value !== '' &&
54
+ value !== primaryLabel &&
55
+ all.indexOf(value) === index
56
+ );
57
+ const isBusy = busyOrgUserId === principalUuid;
58
+ return (
59
+ <button
60
+ key={candidate.uuid}
61
+ type="button"
62
+ className="pc-select-user__item"
63
+ onClick={() => onSelect(principalUuid)}
64
+ disabled={!principalUuid || busyOrgUserId !== null}
65
+ >
66
+ <span className="pc-select-user__item-text">
67
+ <span className="pc-select-user__item-primary">
68
+ {primaryLabel}
69
+ </span>
70
+ {secondaryLabels.map((label) => (
71
+ <span key={label} className="pc-select-user__item-secondary">
72
+ {label}
73
+ </span>
74
+ ))}
75
+ </span>
76
+ {isBusy ? (
77
+ <span className="pc-select-user__item-status">
78
+ {t('signingInEllipsis')}
79
+ </span>
80
+ ) : (
81
+ <ChevronRight size={18} className="pc-select-user__item-chevron" />
82
+ )}
83
+ </button>
84
+ );
85
+ })}
86
+ </div>
87
+
88
+ {error ? (
89
+ <p className="pc-select-user__error" role="alert">
90
+ {error}
91
+ </p>
92
+ ) : null}
93
+ </div>
94
+ );
95
+ };