From e6025e8e68e043d1271c7e92814d229966416e45 Mon Sep 17 00:00:00 2001 From: Jason Gill Date: Thu, 12 Mar 2026 11:59:55 -0600 Subject: [PATCH 01/44] Fix Alert banner variant to use Carbon icons (#7627) Co-authored-by: Claude Opus 4.6 --- .../fidesui/src/components/feedback/Alert.stories.tsx | 10 ++++++++++ clients/fidesui/src/hoc/CustomAlert.tsx | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/clients/fidesui/src/components/feedback/Alert.stories.tsx b/clients/fidesui/src/components/feedback/Alert.stories.tsx index 0d5c42d8ee7..a157378e75f 100644 --- a/clients/fidesui/src/components/feedback/Alert.stories.tsx +++ b/clients/fidesui/src/components/feedback/Alert.stories.tsx @@ -95,6 +95,16 @@ export const Banner: Story = { description: PARAGRAPH_LOREM, banner: true, }, + argTypes: { + type: { + control: "select", + options: Object.values(ALERT_TYPE), + }, + showIcon: { + control: "boolean", + }, + icon: iconControl, + }, }; export const Compact: Story = { diff --git a/clients/fidesui/src/hoc/CustomAlert.tsx b/clients/fidesui/src/hoc/CustomAlert.tsx index 214c3b4bf19..b7bb6bb10db 100644 --- a/clients/fidesui/src/hoc/CustomAlert.tsx +++ b/clients/fidesui/src/hoc/CustomAlert.tsx @@ -8,7 +8,12 @@ export interface CustomAlertProps extends AlertProps {} const withCustomProps = (WrappedComponent: typeof Alert) => { const WrappedAlert = React.forwardRef( - ({ showIcon, icon, type = "info", description, ...props }, ref) => { + ( + { showIcon = false, icon, type = "info", banner, description, ...props }, + ref, + ) => { + // Override Ant's banner-specific defaults so all alert types + // behave consistently. const carbonIcon = showIcon && icon === undefined ? getDefaultAlertIcon(type, !!description) @@ -20,6 +25,7 @@ const withCustomProps = (WrappedComponent: typeof Alert) => { showIcon={showIcon} icon={carbonIcon} type={type} + banner={banner} description={description} {...props} /> @@ -39,6 +45,9 @@ const withCustomProps = (WrappedComponent: typeof Alert) => { * injected based on the alert `type`. Icons are sized at 16px for compact * alerts and 24px when a `description` is present. * + * Banner mode (`banner={true}`) uses the same defaults as all other alert + * types (`showIcon=false`, `type="info"`), overriding Ant's internal defaults. + * * All standard Alert props are supported. Passing a custom `icon` overrides * the Carbon default. */ From 73420db79a039bfdbb9bb76f1d5243a79c70426a Mon Sep 17 00:00:00 2001 From: Tom Van Dort Date: Thu, 12 Mar 2026 14:00:57 -0400 Subject: [PATCH 02/44] ENG-2732: Change description span to div (#7502) --- changelog/ENG-2732-fix-experience-description-html.yaml | 4 ++++ clients/fides-js/src/components/ExperienceDescription.tsx | 3 ++- clients/fides-js/src/components/fides.css | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelog/ENG-2732-fix-experience-description-html.yaml diff --git a/changelog/ENG-2732-fix-experience-description-html.yaml b/changelog/ENG-2732-fix-experience-description-html.yaml new file mode 100644 index 00000000000..c754c580b8a --- /dev/null +++ b/changelog/ENG-2732-fix-experience-description-html.yaml @@ -0,0 +1,4 @@ +type: Changed +description: Changed span to div in ExperienceDescription to expand the set of supported HTML tags in experience descriptions +pr: 7502 +labels: [] diff --git a/clients/fides-js/src/components/ExperienceDescription.tsx b/clients/fides-js/src/components/ExperienceDescription.tsx index c92ee202237..dc19e11d4b9 100644 --- a/clients/fides-js/src/components/ExperienceDescription.tsx +++ b/clients/fides-js/src/components/ExperienceDescription.tsx @@ -13,9 +13,10 @@ const renderString = (string: string, allowHTMLDescription: boolean | null) => { // NOTE: We sanitize these descriptions server-side when configuring the // PrivacyExperience, so it's safe to trust these return allowHTMLDescription ? ( - ) : ( stripHtml(string).trim() diff --git a/clients/fides-js/src/components/fides.css b/clients/fides-js/src/components/fides.css index 1a5b1587005..65243affd8d 100644 --- a/clients/fides-js/src/components/fides.css +++ b/clients/fides-js/src/components/fides.css @@ -254,6 +254,10 @@ div#fides-banner-heading { font-weight: 600; } +.fides-experience-description-part { + display: contents; +} + div#fides-banner-notices { margin-top: 16px; } From 27e34925e6b90f26a78092e2a7dc127e15face05 Mon Sep 17 00:00:00 2001 From: mfbrown Date: Thu, 12 Mar 2026 14:49:42 -0400 Subject: [PATCH 03/44] Feature/add es us language (#7520) --- changelog/7520-add-es-us-language.yaml | 4 +++ .../src/types/api/models/SupportedLanguage.ts | 1 + .../__tests__/lib/i18n/i18n-utils.test.ts | 4 +-- .../src/lib/i18n/locales/es-US/messages.json | 12 +++++++++ .../fides-js/src/lib/i18n/locales/index.ts | 7 +++++ .../tcf/i18n/locales/es-US/messages-tcf.json | 26 +++++++++++++++++++ .../src/lib/tcf/i18n/locales/index.ts | 2 ++ .../types/api/models/SupportedLanguage.ts | 1 + src/fides/data/language/languages.yml | 2 ++ 9 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 changelog/7520-add-es-us-language.yaml create mode 100644 clients/fides-js/src/lib/i18n/locales/es-US/messages.json create mode 100644 clients/fides-js/src/lib/tcf/i18n/locales/es-US/messages-tcf.json diff --git a/changelog/7520-add-es-us-language.yaml b/changelog/7520-add-es-us-language.yaml new file mode 100644 index 00000000000..7366ef7f4d9 --- /dev/null +++ b/changelog/7520-add-es-us-language.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added es-US language code with default translations from es-MX +pr: 7520 +labels: [] diff --git a/clients/admin-ui/src/types/api/models/SupportedLanguage.ts b/clients/admin-ui/src/types/api/models/SupportedLanguage.ts index af6c8fb5cda..11a4336f84d 100644 --- a/clients/admin-ui/src/types/api/models/SupportedLanguage.ts +++ b/clients/admin-ui/src/types/api/models/SupportedLanguage.ts @@ -16,6 +16,7 @@ export enum SupportedLanguage { EN_GB = "en-GB", ES = "es", ES_MX = "es-MX", + ES_US = "es-US", ET = "et", EU = "eu", FI = "fi", diff --git a/clients/fides-js/__tests__/lib/i18n/i18n-utils.test.ts b/clients/fides-js/__tests__/lib/i18n/i18n-utils.test.ts index 89a446db63d..ae08a01ef5b 100644 --- a/clients/fides-js/__tests__/lib/i18n/i18n-utils.test.ts +++ b/clients/fides-js/__tests__/lib/i18n/i18n-utils.test.ts @@ -210,7 +210,7 @@ describe("i18n-utils", () => { const updatedLocales = loadMessagesFromFiles(mockI18n); // Check the updated locales list is what we expect - const EXPECTED_NUM_STATIC_LOCALES = 41; // NOTE: manually update this as new locales added + const EXPECTED_NUM_STATIC_LOCALES = 42; // NOTE: manually update this as new locales added expect(updatedLocales).toHaveLength(EXPECTED_NUM_STATIC_LOCALES); expect(updatedLocales).toContain("en"); expect(mockI18n.load).toHaveBeenCalledTimes(EXPECTED_NUM_STATIC_LOCALES); @@ -244,7 +244,7 @@ describe("i18n-utils", () => { const updatedLocales = loadTcfMessagesFromFiles(mockI18n); // Check the updated locales list is what we expect - const EXPECTED_NUM_STATIC_LOCALES = 41; // NOTE: manually update this as new locales added + const EXPECTED_NUM_STATIC_LOCALES = 42; // NOTE: manually update this as new locales added expect(updatedLocales).toHaveLength(EXPECTED_NUM_STATIC_LOCALES); expect(updatedLocales).toContain("en"); expect(mockI18n.load).toHaveBeenCalledTimes(EXPECTED_NUM_STATIC_LOCALES); diff --git a/clients/fides-js/src/lib/i18n/locales/es-US/messages.json b/clients/fides-js/src/lib/i18n/locales/es-US/messages.json new file mode 100644 index 00000000000..7c3905a5aca --- /dev/null +++ b/clients/fides-js/src/lib/i18n/locales/es-US/messages.json @@ -0,0 +1,12 @@ +{ + "static.gpc": "Control de privacidad global", + "static.gpc.description": "Su preferencia de control de privacidad global se ha respetado. Se le excluyó automáticamente de los casos de uso de datos que se adhieren al control de privacidad global.", + "static.gpc.status.applied": "Aplicado", + "static.gpc.status.overridden": "Anulado", + "static.gpc.title": "Control de privacidad global detectado", + "static.other.cookies": "Cookies", + "static.other.back": "Atrás", + "static.other.vendors": "Proveedores", + "static.other.retention": "Retención", + "static.other.description": "Descripción" +} diff --git a/clients/fides-js/src/lib/i18n/locales/index.ts b/clients/fides-js/src/lib/i18n/locales/index.ts index 9ad80243995..8b453e4f022 100644 --- a/clients/fides-js/src/lib/i18n/locales/index.ts +++ b/clients/fides-js/src/lib/i18n/locales/index.ts @@ -19,6 +19,7 @@ import el from "./el/messages.json"; import en from "./en/messages.json"; import es from "./es/messages.json"; import esMX from "./es-MX/messages.json"; +import esUS from "./es-US/messages.json"; import et from "./et/messages.json"; import eu from "./eu/messages.json"; import fi from "./fi/messages.json"; @@ -62,6 +63,7 @@ export const STATIC_MESSAGES: Record = { en: en, es: es, "es-MX": esMX, + "es-US": esUS, et: et, eu: eu, fi: fi, @@ -110,6 +112,11 @@ export const LOCALE_LANGUAGE_MAP: Language[] = [ label_en: "Spanish (Mexico)", label_original: "Español - MX", }, + { + locale: "es-US", + label_en: "Spanish (United States)", + label_original: "Español - US", + }, { locale: "et", label_en: "Estonian", label_original: "Eesti" }, { locale: "eu", label_en: "Basque", label_original: "euskara" }, { locale: "fi", label_en: "Finnish", label_original: "Suomi" }, diff --git a/clients/fides-js/src/lib/tcf/i18n/locales/es-US/messages-tcf.json b/clients/fides-js/src/lib/tcf/i18n/locales/es-US/messages-tcf.json new file mode 100644 index 00000000000..d8de04d6e46 --- /dev/null +++ b/clients/fides-js/src/lib/tcf/i18n/locales/es-US/messages-tcf.json @@ -0,0 +1,26 @@ +{ + "static.tcf.consent": "Consentimiento", + "static.tcf.cookie_disclosure.also_non_cookie": "Este proveedor también una otros métodos como “almacenamiento local” para almacenar y acceder a información en su dispositivo.", + "static.tcf.cookie_disclosure.intro": "almacena cookies con una duración máxima de alrededor de estos días.", + "static.tcf.cookie_disclosure.non_cookie": "usa métodos como “almacenamiento local” para almacenar y acceder a información en su dispositivo.", + "static.tcf.cookie_disclosure.refresh": "Estas cookies se pueden actualizar.", + "static.tcf.data_categories": "Categorías de datos", + "static.tcf.features": "Características", + "static.tcf.features.description": "A continuación, encontrará una lista de las características para las que se están procesando sus datos. Puede ejercer sus derechos para las características especiales usando los botones a continuación.", + "static.tcf.legint": "Interés legítimo", + "static.tcf.legint_disclosure": "Divulgación de interés legítimo", + "static.tcf.privacy_policy": "Política de privacidad", + "static.tcf.purposes": "Propósitos", + "static.tcf.purposes.description": "A continuación, encontrará una lista de los propósitos y características especiales para los que se están procesando sus datos. Puede ejercer sus derechos para propósitos específicos, basándose en el consentimiento o interés legítimo, usando los botones a continuación.", + "static.tcf.retention": "Retención", + "static.tcf.retention_period_days": "día(s)", + "static.tcf.special_features": "Características especiales", + "static.tcf.special_purposes": "Propósitos especiales", + "static.tcf.vendors": "Proveedores", + "static.tcf.vendors.description": "A continuación, encontrará una lista de los proveedores que procesan sus datos y los propósitos o características de procesamiento que ellos declaran. Puede ejercer sus derechos para cada proveedor basándose en la base legal que afirman.", + "static.tcf.vendors.iab": "Proveedores IAB TCF", + "static.tcf.vendors.other": "Otros proveedores", + "static.tcf.vendors_count": "proveedor(es)", + "static.tcf.vendors_using_consent": "Proveedores que usan consentimiento", + "static.tcf.vendors_using_legint": "Proveedores que usan interés legítimo" +} diff --git a/clients/fides-js/src/lib/tcf/i18n/locales/index.ts b/clients/fides-js/src/lib/tcf/i18n/locales/index.ts index d485f689d04..88ee8c1ca6b 100644 --- a/clients/fides-js/src/lib/tcf/i18n/locales/index.ts +++ b/clients/fides-js/src/lib/tcf/i18n/locales/index.ts @@ -18,6 +18,7 @@ import el from "./el/messages-tcf.json"; import en from "./en/messages-tcf.json"; import es from "./es/messages-tcf.json"; import esMX from "./es-MX/messages-tcf.json"; +import esUS from "./es-US/messages-tcf.json"; import et from "./et/messages-tcf.json"; import eu from "./eu/messages-tcf.json"; import fi from "./fi/messages-tcf.json"; @@ -61,6 +62,7 @@ export const STATIC_MESSAGES_TCF: Record = { en: en, es: es, "es-MX": esMX, + "es-US": esUS, et: et, eu: eu, fi: fi, diff --git a/clients/privacy-center/types/api/models/SupportedLanguage.ts b/clients/privacy-center/types/api/models/SupportedLanguage.ts index edd03251d09..34a5b1ece0c 100644 --- a/clients/privacy-center/types/api/models/SupportedLanguage.ts +++ b/clients/privacy-center/types/api/models/SupportedLanguage.ts @@ -15,6 +15,7 @@ export enum SupportedLanguage { EN_GB = "en-GB", ES = "es", ES_MX = "es-MX", + ES_US = "es-US", ET = "et", EU = "eu", FI = "fi", diff --git a/src/fides/data/language/languages.yml b/src/fides/data/language/languages.yml index 99682912cf3..34f19c81bb3 100644 --- a/src/fides/data/language/languages.yml +++ b/src/fides/data/language/languages.yml @@ -25,6 +25,8 @@ languages: name: Spanish - id: es-MX name: Spanish (Mexico) +- id: es-US + name: Spanish (United States) - id: et name: Estonian - id: eu From c8fbca8d5370bee43a94e322d367c398c723ecf2 Mon Sep 17 00:00:00 2001 From: Dan Sill Date: Thu, 12 Mar 2026 15:45:41 -0400 Subject: [PATCH 04/44] ENG-2810: Enable LLM classifier toggle for Okta monitors (#7641) Co-authored-by: Claude Opus 4.6 --- changelog/7641-enable-llm-okta-monitor.yaml | 4 ++++ .../configure-monitor/ConfigureMonitorForm.tsx | 15 --------------- 2 files changed, 4 insertions(+), 15 deletions(-) create mode 100644 changelog/7641-enable-llm-okta-monitor.yaml diff --git a/changelog/7641-enable-llm-okta-monitor.yaml b/changelog/7641-enable-llm-okta-monitor.yaml new file mode 100644 index 00000000000..2f67396fe5c --- /dev/null +++ b/changelog/7641-enable-llm-okta-monitor.yaml @@ -0,0 +1,4 @@ +type: Added +description: Enabled LLM classifier toggle for Okta infrastructure monitors +pr: 7641 +labels: [] diff --git a/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorForm.tsx b/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorForm.tsx index 720e33dca12..2b7e3891ade 100644 --- a/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorForm.tsx +++ b/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureMonitorForm.tsx @@ -9,10 +9,6 @@ import { useEffect, useState } from "react"; import { LlmModelSelector } from "~/features/common/form/LlmModelSelector"; import { enumToOptions } from "~/features/common/helpers"; import { formatUser } from "~/features/common/utils"; -import { - getMonitorType, - MONITOR_TYPES, -} from "~/features/data-discovery-and-detection/action-center/utils/getMonitorType"; import { useGetSystemByFidesKeyQuery } from "~/features/system"; import { useGetAllUsersQuery } from "~/features/user-management"; import { @@ -100,16 +96,6 @@ const ConfigureMonitorForm = ({ }) => { const isEditing = !!monitor; - const isInfrastructureMonitor = - getMonitorType(integrationOption.identifier as ConnectionType) === - MONITOR_TYPES.INFRASTRUCTURE; - - /** - * Show the LLM classifier option if the monitor is not an infrastructure monitor. - * Infrastructure monitors (e.g., Okta) don't use classification. - */ - const showLLMOption = !isInfrastructureMonitor; - const [form] = Form.useForm(); const { data: systemData, isLoading: isLoadingSystem } = useGetSystemByFidesKeyQuery(integrationSystem || skipToken); @@ -277,7 +263,6 @@ const ConfigureMonitorForm = ({ /> From 8cff75ae4a383023c21166e2fb8ba11a854afefd Mon Sep 17 00:00:00 2001 From: Tom Van Dort Date: Thu, 12 Mar 2026 16:29:25 -0400 Subject: [PATCH 05/44] ENG-2732: Update changelog (#7645) Co-authored-by: Daniel Sill Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 2 ++ changelog/7641-enable-llm-okta-monitor.yaml | 4 ---- changelog/ENG-2732-fix-experience-description-html.yaml | 4 ---- 3 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 changelog/7641-enable-llm-okta-monitor.yaml delete mode 100644 changelog/ENG-2732-fix-experience-description-html.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed04def85b..63f64b11c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o ## [2.81.0](https://github.com/ethyca/fides/compare/2.80.1..2.81.0) ### Added +- Enabled LLM classifier toggle for Okta infrastructure monitors [#7641](https://github.com/ethyca/fides/pull/7641) - Added questionnaire_tone_prompt column to privacy assessment config [#7563](https://github.com/ethyca/fides/pull/7563) https://github.com/ethyca/fides/labels/db-migration - Chromatic builds for fidesui [#7485](https://github.com/ethyca/fides/pull/7485) - Okta integration logo in action center [#7597](https://github.com/ethyca/fides/pull/7597) @@ -81,6 +82,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o - Replaced Ant default icons with Carbon icons in Alert component [#7613](https://github.com/ethyca/fides/pull/7613) - Migrated Organization encrypted columns from pgcrypto to AES-GCM [#7554](https://github.com/ethyca/fides/pull/7554) https://github.com/ethyca/fides/labels/db-migration - Refined chart components with animation state management and dark theme card config [#7537](https://github.com/ethyca/fides/pull/7537) +- Changed span to div in ExperienceDescription to expand the set of supported HTML tags in experience descriptions [#7502](https://github.com/ethyca/fides/pull/7502) ### Developer Experience - Added Carbon icon defaults for Ant Modal imperative API methods [#7569](https://github.com/ethyca/fides/pull/7569) diff --git a/changelog/7641-enable-llm-okta-monitor.yaml b/changelog/7641-enable-llm-okta-monitor.yaml deleted file mode 100644 index 2f67396fe5c..00000000000 --- a/changelog/7641-enable-llm-okta-monitor.yaml +++ /dev/null @@ -1,4 +0,0 @@ -type: Added -description: Enabled LLM classifier toggle for Okta infrastructure monitors -pr: 7641 -labels: [] diff --git a/changelog/ENG-2732-fix-experience-description-html.yaml b/changelog/ENG-2732-fix-experience-description-html.yaml deleted file mode 100644 index c754c580b8a..00000000000 --- a/changelog/ENG-2732-fix-experience-description-html.yaml +++ /dev/null @@ -1,4 +0,0 @@ -type: Changed -description: Changed span to div in ExperienceDescription to expand the set of supported HTML tags in experience descriptions -pr: 7502 -labels: [] From d41bf0f7b254d7232c85210f71927256fe820051 Mon Sep 17 00:00:00 2001 From: JadeWibbels Date: Thu, 12 Mar 2026 17:31:49 -0600 Subject: [PATCH 06/44] ENG-2544: Add DeletedStatus and failed-instance error handling (#7638) Co-authored-by: Jade Wibbels Co-authored-by: Claude Opus 4.6 --- ...638-manual-task-failed-instance-error.yaml | 4 +++ src/fides/api/models/jira_ticket_task.py | 16 ++++++++-- src/fides/api/task/graph_task.py | 5 ++- .../api/task/manual/manual_task_graph_task.py | 32 ++++++++++++++++++- src/fides/api/task/task_resources.py | 5 ++- .../manual/test_manual_task_graph_task.py | 31 ++++++++++++++++++ 6 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 changelog/7638-manual-task-failed-instance-error.yaml diff --git a/changelog/7638-manual-task-failed-instance-error.yaml b/changelog/7638-manual-task-failed-instance-error.yaml new file mode 100644 index 00000000000..974ddf54936 --- /dev/null +++ b/changelog/7638-manual-task-failed-instance-error.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added DeletedStatus enum and failed-instance error handling for manual tasks +pr: 7638 +labels: [] diff --git a/src/fides/api/models/jira_ticket_task.py b/src/fides/api/models/jira_ticket_task.py index ca52baebe5c..7fe726b2808 100644 --- a/src/fides/api/models/jira_ticket_task.py +++ b/src/fides/api/models/jira_ticket_task.py @@ -5,7 +5,9 @@ polling service uses to track ticket lifecycle. """ -from typing import TYPE_CHECKING, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple from sqlalchemy import Column, DateTime, ForeignKey, Index, String, text from sqlalchemy.ext.declarative import declared_attr @@ -20,6 +22,16 @@ DONE_STATUS_CATEGORY = "done" +class DeletedStatus(NamedTuple): + """Synthetic status for tickets that have been removed from Jira.""" + + category: str = "deleted" + display: str = "Deleted" + + +DELETED_STATUS = DeletedStatus() + + class JiraTicketTask(Base): """Tracks a Jira ticket linked to a ManualTaskInstance. @@ -94,6 +106,6 @@ def get_open_tasks(cls, db: Session) -> list["JiraTicketTask"]: @classmethod def get_by_instance_id( cls, db: Session, instance_id: str - ) -> Optional["JiraTicketTask"]: + ) -> "JiraTicketTask | None": """Look up a JiraTicketTask by its ManualTaskInstance ID.""" return db.query(cls).filter(cls.manual_task_instance_id == instance_id).first() diff --git a/src/fides/api/task/graph_task.py b/src/fides/api/task/graph_task.py index 0531223a9c3..e7af00ccf85 100644 --- a/src/fides/api/task/graph_task.py +++ b/src/fides/api/task/graph_task.py @@ -282,7 +282,10 @@ def can_write_data(self) -> bool: # Manual tasks don't connect to external systems, so the write access # concept doesn't apply. Humans manually record erasure confirmations # or consent preferences. - if connection_config.connection_type == ConnectionType.manual_task: + if connection_config.connection_type in ( + ConnectionType.manual_task, + ConnectionType.jira_ticket, + ): return True return connection_config.access == AccessLevel.write diff --git a/src/fides/api/task/manual/manual_task_graph_task.py b/src/fides/api/task/manual/manual_task_graph_task.py index 492e11afa1a..d1a8fd948a7 100644 --- a/src/fides/api/task/manual/manual_task_graph_task.py +++ b/src/fides/api/task/manual/manual_task_graph_task.py @@ -270,7 +270,19 @@ def _set_submitted_data_or_raise_awaiting_async_task_callback( awaiting_detail_message: Optional[str] = None, ) -> Optional[list[Row]]: """ - Set submitted data for a manual task and raise AwaitingAsyncTaskCallback if all instances are not completed + Set submitted data for a manual task and raise AwaitingAsyncTaskCallback if all instances are not completed. + + Order of checks matters: + 1. Submitted data check first — if the user has already submitted data, + the task is complete regardless of instance status. A ``failed`` status + set by an external process (e.g. Jira poller) is superseded by explicit + user submission. + 2. Failed-instance check — only fires when no data has been submitted + AND all instances for this task/action are failed. If a replacement + ticket has been linked (creating a new active instance), we wait for + that instead. Only when every instance is failed do we error, since + no input can ever arrive. + 3. Fall through to AwaitingAsyncTask if neither condition is met. """ # Check if all manual task instances have submissions for this action type submitted_data = self._get_submitted_data( @@ -286,6 +298,24 @@ def _set_submitted_data_or_raise_awaiting_async_task_callback( return result + # Check if all instances for this task/action have been marked as failed + # by an external process (e.g. poller detected a deleted Jira ticket + # with no data). Only error when there are no active instances — if a + # replacement ticket has been linked, a new active instance will exist + # alongside the old failed one, and we should wait for that instead. + matching_instances = [ + inst + for inst in self.resources.request.manual_task_instances + if inst.task_id == manual_task.id and inst.config.config_type == action_type + ] + if matching_instances and all( + inst.status == StatusType.failed for inst in matching_instances + ): + raise ValueError( + f"Manual task for {self.connection_key} has failed instances — " + f"cannot proceed without intervention" + ) + # Set privacy request status to requires_input if not already set if self.resources.request.status != PrivacyRequestStatus.requires_input: self.resources.request.status = PrivacyRequestStatus.requires_input diff --git a/src/fides/api/task/task_resources.py b/src/fides/api/task/task_resources.py index 16b0f4e621c..ec46066f0f4 100644 --- a/src/fides/api/task/task_resources.py +++ b/src/fides/api/task/task_resources.py @@ -97,7 +97,10 @@ def build_connector( # pylint: disable=R0911,R0912 return RDSPostgresConnector(connection_config) if connection_config.connection_type == ConnectionType.scylla: return ScyllaConnector(connection_config) - if connection_config.connection_type == ConnectionType.manual_task: + if connection_config.connection_type in ( + ConnectionType.manual_task, + ConnectionType.jira_ticket, + ): return ManualTaskConnector(connection_config) raise NotImplementedError( f"No connector available for {connection_config.connection_type}" diff --git a/tests/api/task/manual/test_manual_task_graph_task.py b/tests/api/task/manual/test_manual_task_graph_task.py index fd05b83f201..3c64f28d36e 100644 --- a/tests/api/task/manual/test_manual_task_graph_task.py +++ b/tests/api/task/manual/test_manual_task_graph_task.py @@ -8,6 +8,7 @@ ManualTaskFieldType, ManualTaskInstance, ManualTaskSubmission, + StatusType, ) from fides.api.models.manual_task.conditional_dependency import ( ManualTaskConditionalDependency, @@ -1283,6 +1284,36 @@ def test_set_submitted_data_or_raise_awaiting_async_task_callback_with_awaiting_ awaiting_detail_message="Test detail", ) + def test_set_submitted_data_raises_error_for_failed_instance( + self, + build_graph_task, + manual_task_instance, + access_privacy_request, + db, + ): + """When all instances are failed (e.g. deleted Jira ticket), raise ValueError instead of waiting.""" + manual_task, graph_task = build_graph_task + manual_task_instance.status = StatusType.failed + manual_task_instance.save(db) + db.refresh(access_privacy_request) + + with ( + patch.object( + graph_task, + "_get_submitted_data", + autospec=True, + return_value=None, + ), + ): + with pytest.raises( + ValueError, + match="has failed instances", + ): + graph_task._set_submitted_data_or_raise_awaiting_async_task_callback( + manual_task, + ActionType.access, + ) + def test_ensure_manual_task_instances_with_config( self, manual_task_graph_task, From 9de3a2d89b0aa507fa4bf6635881269a79c0b888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Reyes?= <93041918+nreyes-dev@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:23:36 -0300 Subject: [PATCH 07/44] ENG-2920: Allow for showing multiple links in privacy center + show them below forms too (#7612) --- changelog/7612-privacy-center-multi-link.yaml | 4 ++ clients/admin-ui/src/types/api/index.ts | 1 + .../types/api/models/PrivacyCenterConfig.ts | 4 ++ .../src/types/api/models/PrivacyCenterLink.ts | 8 +++ .../__tests__/common/config-links.test.ts | 52 +++++++++++++++++++ clients/privacy-center/common/config-links.ts | 34 ++++++++++++ .../privacy-center/components/HomePage.tsx | 42 ++++++++------- .../components/common/AuthFormLayout.tsx | 20 ++++++- clients/privacy-center/config/config.json | 3 +- clients/privacy-center/types/api/index.ts | 1 + .../types/api/models/PrivacyCenterConfig.ts | 4 ++ .../types/api/models/PrivacyCenterLink.ts | 8 +++ clients/privacy-center/types/config.ts | 8 +++ .../api/schemas/privacy_center_config.py | 15 ++++++ 14 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 changelog/7612-privacy-center-multi-link.yaml create mode 100644 clients/admin-ui/src/types/api/models/PrivacyCenterLink.ts create mode 100644 clients/privacy-center/__tests__/common/config-links.test.ts create mode 100644 clients/privacy-center/common/config-links.ts create mode 100644 clients/privacy-center/types/api/models/PrivacyCenterLink.ts diff --git a/changelog/7612-privacy-center-multi-link.yaml b/changelog/7612-privacy-center-multi-link.yaml new file mode 100644 index 00000000000..b07b2280dfb --- /dev/null +++ b/changelog/7612-privacy-center-multi-link.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added support for multiple configurable footer links in the Privacy Center, now visible on both the home and form pages +pr: 7612 +labels: [] diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index 12d2332e5b9..cf6fa34f17d 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -452,6 +452,7 @@ export type { PlusApplicationConfig } from "./models/PlusApplicationConfig"; export type { PlusConsentSettingsApplicationConfig } from "./models/PlusConsentSettingsApplicationConfig"; export type { PolicyConditionRequest } from "./models/PolicyConditionRequest"; export type { PolicyConditionResponse } from "./models/PolicyConditionResponse"; +export type { PrivacyCenterLink } from "./models/PrivacyCenterLink"; export type { PolicyMaskingSpec } from "./models/PolicyMaskingSpec"; export type { PolicyMaskingSpecResponse } from "./models/PolicyMaskingSpecResponse"; export type { PolicyResponse } from "./models/PolicyResponse"; diff --git a/clients/admin-ui/src/types/api/models/PrivacyCenterConfig.ts b/clients/admin-ui/src/types/api/models/PrivacyCenterConfig.ts index 9d6b3adac03..4cf0c971177 100644 --- a/clients/admin-ui/src/types/api/models/PrivacyCenterConfig.ts +++ b/clients/admin-ui/src/types/api/models/PrivacyCenterConfig.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { fides__api__schemas__privacy_center_config__ConsentConfig } from "./fides__api__schemas__privacy_center_config__ConsentConfig"; +import type { PrivacyCenterLink } from "./PrivacyCenterLink"; import type { PolicyUnavailableMessages } from "./PolicyUnavailableMessages"; import type { PrivacyRequestOption } from "./PrivacyRequestOption"; @@ -25,7 +26,10 @@ export type PrivacyCenterConfig = { actions: Array; includeConsent?: boolean | null; consent: fides__api__schemas__privacy_center_config__ConsentConfig; + /** @deprecated Prefer `links`. Kept for backwards compatibility. */ privacy_policy_url?: string | null; + /** @deprecated Prefer `links`. Kept for backwards compatibility. */ privacy_policy_url_text?: string | null; + links?: Array; policy_unavailable_messages?: PolicyUnavailableMessages | null; }; diff --git a/clients/admin-ui/src/types/api/models/PrivacyCenterLink.ts b/clients/admin-ui/src/types/api/models/PrivacyCenterLink.ts new file mode 100644 index 00000000000..cc966909719 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/PrivacyCenterLink.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type PrivacyCenterLink = { + label: string; + url: string; +}; diff --git a/clients/privacy-center/__tests__/common/config-links.test.ts b/clients/privacy-center/__tests__/common/config-links.test.ts new file mode 100644 index 00000000000..3fcade16871 --- /dev/null +++ b/clients/privacy-center/__tests__/common/config-links.test.ts @@ -0,0 +1,52 @@ +import { getEffectivePrivacyCenterLinks } from "~/common/config-links"; + +describe("getEffectivePrivacyCenterLinks", () => { + it("returns links when links array is non-empty", () => { + const result = getEffectivePrivacyCenterLinks({ + links: [ + { label: "Privacy Policy", url: "https://example.com/privacy" }, + { label: "Terms of Service", url: "https://example.com/terms" }, + ], + }); + expect(result).toEqual([ + { label: "Privacy Policy", url: "https://example.com/privacy" }, + { label: "Terms of Service", url: "https://example.com/terms" }, + ]); + }); + + it("synthesizes a single link from deprecated fields when links is absent", () => { + const result = getEffectivePrivacyCenterLinks({ + privacy_policy_url: "https://example.com/privacy", + privacy_policy_url_text: "Privacy Policy", + }); + expect(result).toEqual([ + { url: "https://example.com/privacy", label: "Privacy Policy" }, + ]); + }); + + it("prefers links over deprecated fields when both are present", () => { + const result = getEffectivePrivacyCenterLinks({ + links: [{ label: "Terms", url: "https://example.com/terms" }], + privacy_policy_url: "https://example.com/privacy", + privacy_policy_url_text: "Privacy Policy", + }); + expect(result).toEqual([ + { label: "Terms", url: "https://example.com/terms" }, + ]); + }); + + it("returns empty array when links is empty and deprecated fields are absent", () => { + expect(getEffectivePrivacyCenterLinks({})).toEqual([]); + }); + + it("falls through to deprecated fields when links is an empty array", () => { + const result = getEffectivePrivacyCenterLinks({ + links: [], + privacy_policy_url: "https://example.com/privacy", + privacy_policy_url_text: "Privacy Policy", + }); + expect(result).toEqual([ + { url: "https://example.com/privacy", label: "Privacy Policy" }, + ]); + }); +}); diff --git a/clients/privacy-center/common/config-links.ts b/clients/privacy-center/common/config-links.ts new file mode 100644 index 00000000000..650d0957ff3 --- /dev/null +++ b/clients/privacy-center/common/config-links.ts @@ -0,0 +1,34 @@ +import { PrivacyCenterLink } from "~/types/config"; + +type ConfigWithLinks = { + links?: PrivacyCenterLink[]; + privacy_policy_url?: string | null; + privacy_policy_url_text?: string | null; +}; + +/** + * Returns the list of policy links to display, resolving both the new `links` + * field and the deprecated `privacy_policy_url` / `privacy_policy_url_text` + * fields for backwards compatibility. + * + * Resolution order: + * 1. If `links` is non-empty, use it. + * 2. If the deprecated fields are both present, synthesize a single-item list. + * 3. Otherwise return an empty array. + */ +export const getEffectivePrivacyCenterLinks = ( + config: ConfigWithLinks | undefined | null, +): PrivacyCenterLink[] => { + if (!config) { + return []; + } + if (config.links && config.links.length > 0) { + return config.links; + } + if (config.privacy_policy_url && config.privacy_policy_url_text) { + return [ + { url: config.privacy_policy_url, label: config.privacy_policy_url_text }, + ]; + } + return []; +}; diff --git a/clients/privacy-center/components/HomePage.tsx b/clients/privacy-center/components/HomePage.tsx index 3b37de75bf5..c5f037faff6 100644 --- a/clients/privacy-center/components/HomePage.tsx +++ b/clients/privacy-center/components/HomePage.tsx @@ -1,6 +1,7 @@ "use client"; import { + ChakraBox as Box, ChakraFlex as Flex, ChakraHeading as Heading, ChakraLink as Link, @@ -15,6 +16,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import React, { ReactNode, useEffect, useState } from "react"; import { useAppSelector } from "~/app/hooks"; +import { getEffectivePrivacyCenterLinks } from "~/common/config-links"; import { encodePolicyKey } from "~/common/policy-key"; import sanitizeHTML from "~/common/sanitize-html"; import { ConfigErrorToastOptions } from "~/common/toast-options"; @@ -84,8 +86,7 @@ const HomePage: NextPage = () => { const { SHOW_BRAND_LINK, ALLOW_HTML_DESCRIPTION } = useSettings(); - const showPrivacyPolicyLink = - !!config.privacy_policy_url && !!config.privacy_policy_url_text; + const policyLinks = getEffectivePrivacyCenterLinks(config); // Subscribe to experiences just to see if there are any notices. // The subscription automatically handles skipping if overlay is not enabled @@ -235,23 +236,26 @@ const HomePage: NextPage = () => { ))} - {(SHOW_BRAND_LINK || showPrivacyPolicyLink) && ( - - {showPrivacyPolicyLink && ( - - {config.privacy_policy_url_text} - - )} - - + {(SHOW_BRAND_LINK || policyLinks.length > 0) && ( + + + {policyLinks.map(({ url, label }) => ( + + {label} + + ))} + + + )} diff --git a/clients/privacy-center/components/common/AuthFormLayout.tsx b/clients/privacy-center/components/common/AuthFormLayout.tsx index b7628b83802..85bcfe1c7a1 100644 --- a/clients/privacy-center/components/common/AuthFormLayout.tsx +++ b/clients/privacy-center/components/common/AuthFormLayout.tsx @@ -9,9 +9,10 @@ * Used by both ExternalAuthLayout and PrivacyRequestLayout to avoid duplication. */ -import { Flex, Space, Typography } from "fidesui"; +import { Flex, Link, Space, Typography } from "fidesui"; import React from "react"; +import { getEffectivePrivacyCenterLinks } from "~/common/config-links"; import { useConfig } from "~/features/common/config.slice"; import styles from "./AuthFormLayout.module.scss"; @@ -30,6 +31,7 @@ export const AuthFormLayout = ({ dataTestId = "auth-form-layout", }: AuthFormLayoutProps) => { const config = useConfig(); + const policyLinks = getEffectivePrivacyCenterLinks(config); return ( {children} + + {/* Policy Links */} + {policyLinks.length > 0 && ( + + {policyLinks.map(({ url, label }) => ( + + {label} + + ))} + + )} diff --git a/clients/privacy-center/config/config.json b/clients/privacy-center/config/config.json index 155f763fecf..efe19ae6b96 100644 --- a/clients/privacy-center/config/config.json +++ b/clients/privacy-center/config/config.json @@ -5,8 +5,7 @@ "addendum": [], "logo_path": "/logo.svg", "logo_url": "https://fid.es", - "privacy_policy_url": "https://fid.es/privacy", - "privacy_policy_url_text": "Privacy Policy", + "links": [{ "label": "Privacy Policy", "url": "https://fid.es/privacy" }], "favicon_path": "/favicon.ico", "page_title": "Privacy Center", diff --git a/clients/privacy-center/types/api/index.ts b/clients/privacy-center/types/api/index.ts index 77ff39f84bc..cabb9cc093b 100644 --- a/clients/privacy-center/types/api/index.ts +++ b/clients/privacy-center/types/api/index.ts @@ -303,6 +303,7 @@ export type { ParamValue } from "./models/ParamValue"; export type { PartialPrivacyCenterConfig } from "./models/PartialPrivacyCenterConfig"; export type { PartialPrivacyRequestOption } from "./models/PartialPrivacyRequestOption"; export type { PlusApplicationConfig } from "./models/PlusApplicationConfig"; +export type { PrivacyCenterLink } from "./models/PrivacyCenterLink"; export type { PolicyMaskingSpec } from "./models/PolicyMaskingSpec"; export type { PolicyMaskingSpecResponse } from "./models/PolicyMaskingSpecResponse"; export type { PolicyResponse } from "./models/PolicyResponse"; diff --git a/clients/privacy-center/types/api/models/PrivacyCenterConfig.ts b/clients/privacy-center/types/api/models/PrivacyCenterConfig.ts index 0993070233f..348b9fc7db9 100644 --- a/clients/privacy-center/types/api/models/PrivacyCenterConfig.ts +++ b/clients/privacy-center/types/api/models/PrivacyCenterConfig.ts @@ -3,6 +3,7 @@ /* eslint-disable */ import type { fides__api__schemas__privacy_center_config__ConsentConfig } from "./fides__api__schemas__privacy_center_config__ConsentConfig"; +import type { PrivacyCenterLink } from "./PrivacyCenterLink"; import type { PrivacyRequestOption } from "./PrivacyRequestOption"; /** @@ -25,6 +26,9 @@ export type PrivacyCenterConfig = { actions: Array; includeConsent?: boolean | null; consent: fides__api__schemas__privacy_center_config__ConsentConfig; + /** @deprecated Prefer `links`. Kept for backwards compatibility. */ privacy_policy_url?: string | null; + /** @deprecated Prefer `links`. Kept for backwards compatibility. */ privacy_policy_url_text?: string | null; + links?: Array; }; diff --git a/clients/privacy-center/types/api/models/PrivacyCenterLink.ts b/clients/privacy-center/types/api/models/PrivacyCenterLink.ts new file mode 100644 index 00000000000..cc966909719 --- /dev/null +++ b/clients/privacy-center/types/api/models/PrivacyCenterLink.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type PrivacyCenterLink = { + label: string; + url: string; +}; diff --git a/clients/privacy-center/types/config.ts b/clients/privacy-center/types/config.ts index 0f565e6a410..2960f9b92cb 100644 --- a/clients/privacy-center/types/config.ts +++ b/clients/privacy-center/types/config.ts @@ -89,8 +89,16 @@ export type Config = { actions: PrivacyRequestOption[]; includeConsent?: boolean; consent?: ConsentConfig; + /** @deprecated Prefer `links`. Kept for backwards compatibility. */ privacy_policy_url?: string; + /** @deprecated Prefer `links`. Kept for backwards compatibility. */ privacy_policy_url_text?: string; + links?: PrivacyCenterLink[]; +}; + +export type PrivacyCenterLink = { + label: string; + url: string; }; export type LegacyConsentConfig = { diff --git a/src/fides/api/schemas/privacy_center_config.py b/src/fides/api/schemas/privacy_center_config.py index eaf029a0b65..f2bf96326b1 100644 --- a/src/fides/api/schemas/privacy_center_config.py +++ b/src/fides/api/schemas/privacy_center_config.py @@ -17,6 +17,18 @@ RequiredType = Literal["optional", "required"] +class PrivacyCenterLink(FidesSchema): + label: str + url: str + + @field_validator("url") + @classmethod + def validate_url_scheme(cls, v: str) -> str: + if not v.startswith(("http://", "https://")): + raise ValueError("url must use the http or https scheme") + return v + + class CustomIdentity(FidesSchema): label: str @@ -218,8 +230,11 @@ class PrivacyCenterConfig(FidesSchema): actions: List[PrivacyRequestOption] include_consent: Optional[bool] = Field(alias="includeConsent", default=None) consent: ConsentConfig + # Deprecated: prefer `links`. Kept for backwards compatibility. privacy_policy_url: Optional[str] = None + # Deprecated: prefer `links`. Kept for backwards compatibility. privacy_policy_url_text: Optional[str] = None + links: List[PrivacyCenterLink] = [] policy_unavailable_messages: Optional[PolicyUnavailableMessages] = None From ceaf15a7ada71f95452dab38f7c0e50ef103683c Mon Sep 17 00:00:00 2001 From: 3nder Date: Fri, 13 Mar 2026 11:42:04 -0400 Subject: [PATCH 08/44] refactor: okta ui updates [ENG-2809] (#7596) --- changelog/7596.yaml | 4 ++ .../infrastructure-systems.cy.ts | 20 +++--- .../InfrastructureSystemActionsCell.tsx | 32 +++++---- .../InfrastructureSystemListItem.tsx | 55 ++++++++------- .../action-center/constants.ts | 2 +- .../useInfrastructureSystemsBulkActions.tsx | 6 +- .../DiscoveredInfrastructureSystemsTable.tsx | 7 ++ .../infrastructureSystemsBulkActionsMenu.tsx | 68 ++++++++----------- 8 files changed, 101 insertions(+), 93 deletions(-) create mode 100644 changelog/7596.yaml diff --git a/changelog/7596.yaml b/changelog/7596.yaml new file mode 100644 index 00000000000..cbb3df69f37 --- /dev/null +++ b/changelog/7596.yaml @@ -0,0 +1,4 @@ +type: Changed # One of: Added, Changed, Developer Experience, Deprecated, Docs, Fixed, Removed, Security +description: Okta ux refactor +pr: 7596 # PR number +labels: [] # Optional: ["high-risk", "db-migration"] diff --git a/clients/admin-ui/cypress/e2e/action-center/infrastructure-systems.cy.ts b/clients/admin-ui/cypress/e2e/action-center/infrastructure-systems.cy.ts index 38c73d664db..ee7830cc72b 100644 --- a/clients/admin-ui/cypress/e2e/action-center/infrastructure-systems.cy.ts +++ b/clients/admin-ui/cypress/e2e/action-center/infrastructure-systems.cy.ts @@ -502,19 +502,19 @@ describe("Action center infrastructure systems", () => { cy.contains("button", "Actions").should("not.be.disabled"); }); - it("should show Add and Ignore actions on non-ignored tab", () => { + it("should show Approve and Ignore actions on non-ignored tab", () => { cy.visit(`${ACTION_CENTER_ROUTE}/infrastructure/${monitorId}`); cy.wait("@getInfrastructureSystems"); cy.get('input[type="checkbox"]').eq(1).check(); cy.contains("button", "Actions").click(); - cy.contains("Add").should("exist"); + cy.contains("Approve").should("exist"); cy.contains("Ignore").should("exist"); cy.contains("Restore").should("not.exist"); }); - it("should show Add and Restore actions when filtering for ignored systems", () => { + it("should show Approve and Restore actions when filtering for ignored systems", () => { stubInfrastructureSystems({ fixture: "detection-discovery/results/infrastructure-systems-ignored-tab.json", @@ -530,20 +530,20 @@ describe("Action center infrastructure systems", () => { cy.get('input[type="checkbox"]').eq(1).check(); cy.contains("button", "Actions").click(); - cy.get(".ant-dropdown-menu-item").contains("Add").should("exist"); + cy.get(".ant-dropdown-menu-item").contains("Approve").should("exist"); cy.get(".ant-dropdown-menu-item").contains("Restore").should("exist"); cy.get(".ant-dropdown-menu-item") .contains("Ignore") .should("not.exist"); }); - it("should perform bulk Add action with explicit selection", () => { + it("should perform bulk Approve action with explicit selection", () => { cy.visit(`${ACTION_CENTER_ROUTE}/infrastructure/${monitorId}`); cy.wait("@getInfrastructureSystems"); cy.get('input[type="checkbox"]').eq(1).check(); cy.contains("button", "Actions").click(); - cy.contains("Add").click(); + cy.contains("Approve").click(); cy.wait("@bulkPromoteInfrastructureSystems").then((interception) => { expect(interception.request.body).to.be.an("array"); @@ -596,7 +596,7 @@ describe("Action center infrastructure systems", () => { cy.contains("selected").should("not.exist"); }); - it("should perform bulk Add action with select all mode", () => { + it("should perform bulk Approve action with select all mode", () => { stubInfrastructureSystems({ fixture: "detection-discovery/results/infrastructure-systems-large-dataset.json", @@ -607,7 +607,7 @@ describe("Action center infrastructure systems", () => { cy.contains("Select all").click(); cy.contains("button", "Actions").click(); - cy.contains("Add").click(); + cy.contains("Approve").click(); cy.wait("@bulkPromoteInfrastructureSystems").then((interception) => { expect(interception.request.body).to.have.property("filters"); @@ -621,7 +621,7 @@ describe("Action center infrastructure systems", () => { cy.get('input[type="checkbox"]').eq(1).check(); cy.contains("button", "Actions").click(); - cy.contains("Add").click(); + cy.contains("Approve").click(); cy.contains("button", "Actions").should("be.disabled"); }); @@ -639,7 +639,7 @@ describe("Action center infrastructure systems", () => { cy.get('input[type="checkbox"]').eq(1).uncheck(); cy.contains("button", "Actions").click(); - cy.contains("Add").click(); + cy.contains("Approve").click(); cy.wait("@bulkPromoteInfrastructureSystems").then((interception) => { expect(interception.request.body).to.have.property("exclude_urns"); diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemActionsCell.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemActionsCell.tsx index 4bd663c3db5..32a6e56ec39 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemActionsCell.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemActionsCell.tsx @@ -22,7 +22,7 @@ interface InfrastructureSystemActionsCellProps { allowIgnore?: boolean; allowRestore?: boolean; activeTab?: ActionCenterTabHash | null; - addIcon?: React.ReactNode; + approveIcon?: React.ReactNode; ignoreIcon?: React.ReactNode; onPromoteSuccess?: () => void; } @@ -33,7 +33,7 @@ export const InfrastructureSystemActionsCell = ({ allowIgnore, allowRestore, activeTab, - addIcon = , + approveIcon = , ignoreIcon = , onPromoteSuccess, }: InfrastructureSystemActionsCellProps) => { @@ -52,7 +52,7 @@ export const InfrastructureSystemActionsCell = ({ const messageApi = useMessage(); - const handleAdd = async () => { + const handleApprove = async () => { if (!system.urn) { messageApi.error("Cannot promote: system URN is missing"); return; @@ -125,20 +125,20 @@ export const InfrastructureSystemActionsCell = ({ const getActionTooltip = ( action: - | InfrastructureSystemBulkActionType.ADD + | InfrastructureSystemBulkActionType.APPROVE | InfrastructureSystemBulkActionType.RESTORE, ) => { - const isAdd = action === InfrastructureSystemBulkActionType.ADD; + const isApprove = action === InfrastructureSystemBulkActionType.APPROVE; if (!system.urn) { - return `This system cannot be ${isAdd ? "promoted" : "restored"}: URN is missing.`; + return `This system cannot be ${isApprove ? "promoted" : "restored"}: URN is missing.`; } - if (isAdd && isIgnored) { + if (isApprove && isIgnored) { return "Restore systems before adding to the inventory"; } - if (!isAdd && !isIgnored) { + if (!isApprove && !isIgnored) { return "You can only restore ignored systems"; } - return isAdd ? "Add" : "Restore"; + return isApprove ? "Approve" : "Restore"; }; return ( @@ -174,17 +174,19 @@ export const InfrastructureSystemActionsCell = ({ )} - + diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemListItem.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemListItem.tsx index 17ecce193cb..40cc42bc1de 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemListItem.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/components/InfrastructureSystemListItem.tsx @@ -153,34 +153,37 @@ export const InfrastructureSystemListItem = ({ } description={ - { - // Show sparkle icon if the data use was auto-detected (in data_uses) - // and not manually assigned (not in user_assigned_data_uses) - const isAutoDetectedFromCompass = item.data_uses?.includes( - props.value as string, - ); + <> + {item.description} + { + // Show sparkle icon if the data use was auto-detected (in data_uses) + // and not manually assigned (not in user_assigned_data_uses) + const isAutoDetectedFromCompass = item.data_uses?.includes( + props.value as string, + ); - const handleClose = () => { - const newDataUses = - item.preferred_data_uses?.filter( - (dataUse) => dataUse !== props.value, - ) ?? []; - handleUpdateDataUses(newDataUses); - }; + const handleClose = () => { + const newDataUses = + item.preferred_data_uses?.filter( + (dataUse) => dataUse !== props.value, + ) ?? []; + handleUpdateDataUses(newDataUses); + }; - return tagRender({ - ...props, - isFromClassifier: isAutoDetectedFromCompass, - onClose: handleClose, - }); - }} - onSelectDataUse={handleSelectDataUse} - disabled={dataUsesDisabled} - /> + return tagRender({ + ...props, + isFromClassifier: isAutoDetectedFromCompass, + onClose: handleClose, + }); + }} + onSelectDataUse={handleSelectDataUse} + disabled={dataUsesDisabled} + /> + } /> diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/constants.ts b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/constants.ts index fcaa38629f5..012ab4a456d 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/constants.ts +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/constants.ts @@ -126,7 +126,7 @@ export enum ConfidenceLevelLabel { } export enum InfrastructureSystemBulkActionType { - ADD = "add", + APPROVE = "approve", IGNORE = "ignore", RESTORE = "restore", } diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useInfrastructureSystemsBulkActions.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useInfrastructureSystemsBulkActions.tsx index 43f472fe35f..f8df89533b9 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useInfrastructureSystemsBulkActions.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useInfrastructureSystemsBulkActions.tsx @@ -74,7 +74,7 @@ export const useInfrastructureSystemsBulkActions = ({ count = urns.length; - if (action === InfrastructureSystemBulkActionType.ADD) { + if (action === InfrastructureSystemBulkActionType.APPROVE) { result = await bulkPromoteIdentityProviderMonitorResultsMutation({ monitor_config_key: monitorId, urns, @@ -129,7 +129,7 @@ export const useInfrastructureSystemsBulkActions = ({ // Use a placeholder that will be updated from the response count = 0; - if (action === InfrastructureSystemBulkActionType.ADD) { + if (action === InfrastructureSystemBulkActionType.APPROVE) { result = await bulkPromoteIdentityProviderMonitorResultsMutation({ monitor_config_key: monitorId, bulkSelection: bulkSelectionPayload, @@ -172,7 +172,7 @@ export const useInfrastructureSystemsBulkActions = ({ finalCount = 0; } - if (action === InfrastructureSystemBulkActionType.ADD) { + if (action === InfrastructureSystemBulkActionType.APPROVE) { successMessage = `${finalCount} system${finalCount > 1 ? "s" : ""} ${finalCount > 1 ? "have" : "has"} been promoted to the system inventory.`; } else if (action === InfrastructureSystemBulkActionType.IGNORE) { successMessage = `${finalCount} system${finalCount > 1 ? "s" : ""} ${finalCount > 1 ? "have" : "has"} been ignored.`; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredInfrastructureSystemsTable.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredInfrastructureSystemsTable.tsx index ed1abdb4592..f166d761427 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredInfrastructureSystemsTable.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredInfrastructureSystemsTable.tsx @@ -1,4 +1,5 @@ import { + Alert, Button, Checkbox, Dropdown, @@ -146,6 +147,12 @@ export const DiscoveredInfrastructureSystemsTable = ({ return ( + { - if (isIgnoredTab) { - return [ - { - key: InfrastructureSystemBulkActionType.ADD, - label: "Add", - onClick: () => onBulkAction(InfrastructureSystemBulkActionType.ADD), - disabled: isBulkActionInProgress, - }, - { - key: InfrastructureSystemBulkActionType.RESTORE, - label: "Restore", - onClick: () => onBulkAction(InfrastructureSystemBulkActionType.RESTORE), - disabled: isBulkActionInProgress, - }, - ]; - } - - return [ - { - key: InfrastructureSystemBulkActionType.ADD, - label: "Add", - onClick: () => onBulkAction(InfrastructureSystemBulkActionType.ADD), - disabled: isBulkActionInProgress, - }, - ...(allowIgnore - ? [ - { - key: InfrastructureSystemBulkActionType.IGNORE, - label: "Ignore", - onClick: () => - onBulkAction(InfrastructureSystemBulkActionType.IGNORE), - disabled: isBulkActionInProgress, - }, - ] - : []), - ]; -}; +}: GetBulkActionsMenuItemsConfig): MenuProps["items"] => [ + { + key: APPROVE, + label: "Approve", + onClick: () => onBulkAction(APPROVE), + disabled: isBulkActionInProgress, + }, + ...(isIgnoredTab + ? [ + { + key: RESTORE, + label: "Restore", + onClick: () => onBulkAction(RESTORE), + disabled: isBulkActionInProgress, + }, + ] + : []), + ...(allowIgnore + ? [ + { + key: IGNORE, + label: "Ignore", + onClick: () => onBulkAction(IGNORE), + disabled: isBulkActionInProgress, + }, + ] + : []), +]; export const shouldAllowIgnore = ( activeDiffStatusFilters: DiffStatus[] | DiffStatus | undefined, From 88b51dfe200b4c108d1b852aafc7ce352c75f834 Mon Sep 17 00:00:00 2001 From: Jason Gill Date: Fri, 13 Mar 2026 10:04:48 -0600 Subject: [PATCH 09/44] Bump DSR Policies feature flag to Beta (#7649) --- clients/admin-ui/src/features/common/nav/nav-config.tsx | 2 +- clients/admin-ui/src/flags.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clients/admin-ui/src/features/common/nav/nav-config.tsx b/clients/admin-ui/src/features/common/nav/nav-config.tsx index cab4d164f80..87c229459f7 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -130,7 +130,7 @@ export const NAV_CONFIG: NavConfigGroup[] = [ { title: "Policies", path: routes.POLICIES_ROUTE, - requiresFlag: "alphaPolicies", + requiresFlag: "policies", scopes: [ScopeRegistryEnum.POLICY_READ], }, ], diff --git a/clients/admin-ui/src/flags.json b/clients/admin-ui/src/flags.json index 3b4777657fe..31d43385139 100644 --- a/clients/admin-ui/src/flags.json +++ b/clients/admin-ui/src/flags.json @@ -70,8 +70,8 @@ "test": false, "production": false }, - "alphaPolicies": { - "label": "Alpha DSR Policies", + "policies": { + "label": "DSR Policies", "description": "Privacy request policies management", "development": true, "test": true, From 672b8b540ab743da7ef4f6ee9cf933a85fbc7912 Mon Sep 17 00:00:00 2001 From: Eliana Rosselli <67162025+erosselli@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:54:22 -0300 Subject: [PATCH 10/44] Improve changelog validations (#7637) Co-authored-by: Claude Opus 4.6 --- .github/workflows/check_changelog.yml | 3 +- .../7637-improve-changelog-validations.yaml | 4 + changelog/TEMPLATE.yaml | 2 +- noxfiles/changelog_nox.py | 279 +++++++++++------- 4 files changed, 174 insertions(+), 114 deletions(-) create mode 100644 changelog/7637-improve-changelog-validations.yaml diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml index 7b50429b3e3..f28e5b70d54 100644 --- a/.github/workflows/check_changelog.yml +++ b/.github/workflows/check_changelog.yml @@ -58,6 +58,7 @@ jobs: console.log(`✅ Found changelog entry file(s): ${fileNames}`); // Set output for validation step core.setOutput('changelog_files', fileNames); + core.setOutput('pr_number', pr.number.toString()); } - name: Checkout repository @@ -95,4 +96,4 @@ jobs: - name: Validate changelog entries if: success() run: | - uv run nox -s "changelog(validate)" -- --files "${{ steps.check-changelog-files.outputs.changelog_files }}" + uv run nox -s "changelog(validate)" -- --files "${{ steps.check-changelog-files.outputs.changelog_files }}" --pr-number "${{ steps.check-changelog-files.outputs.pr_number }}" diff --git a/changelog/7637-improve-changelog-validations.yaml b/changelog/7637-improve-changelog-validations.yaml new file mode 100644 index 00000000000..c6ab11bf56a --- /dev/null +++ b/changelog/7637-improve-changelog-validations.yaml @@ -0,0 +1,4 @@ +type: Developer Experience +description: Improved changelog fragment validation with PR number matching, filename format enforcement, placeholder description detection, label validation, and trailing whitespace stripping. +pr: 7637 +labels: [] diff --git a/changelog/TEMPLATE.yaml b/changelog/TEMPLATE.yaml index fae7b46feb0..4b6d8f255bf 100644 --- a/changelog/TEMPLATE.yaml +++ b/changelog/TEMPLATE.yaml @@ -1,4 +1,4 @@ -# Copy this file and rename it (e.g., pr-number.yaml or feature-name.yaml) +# Copy this file and rename it to -.yaml (e.g., 1234-add-user-endpoint.yaml) # Fill in the required fields and delete this comment block type: Added # One of: Added, Changed, Developer Experience, Deprecated, Docs, Fixed, Removed, Security diff --git a/noxfiles/changelog_nox.py b/noxfiles/changelog_nox.py index 3904c3127d9..fdd9c5717a7 100644 --- a/noxfiles/changelog_nox.py +++ b/noxfiles/changelog_nox.py @@ -29,6 +29,13 @@ TEMPLATE_FILE_NAME = "TEMPLATE.yaml" GITHUB_REPO = "https://github.com/ethyca/fides" +PLACEHOLDER_DESCRIPTIONS = { + "Short description of the change", + "Description of the change", +} + +FILENAME_PATTERN = re.compile(r"^(\d+)-.+\.ya?ml$") + class ChangelogEntry: """Represents a single changelog entry from a YAML fragment.""" @@ -47,12 +54,11 @@ def __init__( def to_markdown(self) -> str: """Convert entry to markdown format.""" - entry = f"- {self.description}" + entry = f"- {self.description.strip()}" if self.pr: entry += f" [#{self.pr}]({GITHUB_REPO}/pull/{self.pr})" for label in self.labels: - if label in LABEL_URLS: - entry += f" {LABEL_URLS[label]}" + entry += f" {LABEL_URLS[label]}" return entry @@ -89,6 +95,13 @@ def validate_fragment_data( if missing: return None, f"Missing required fields in {filename}: {', '.join(missing)}" + # Validate pr is an integer + if not isinstance(data["pr"], int): + return None, ( + f"'pr' must be an integer in {filename} (e.g., pr: 1234), " + f"got: {data['pr']!r}" + ) + entry_type = data["type"] if entry_type not in CHANGELOG_TYPES: return None, ( @@ -96,50 +109,89 @@ def validate_fragment_data( f"Must be one of: {', '.join(CHANGELOG_TYPES)}" ) + # Validate description is not a placeholder + description = data["description"].strip() + if description in PLACEHOLDER_DESCRIPTIONS: + return None, ( + f"Placeholder description in {filename}: '{description}'. " + f"Please write an actual description of the change." + ) + + # Validate filename format: -.yaml + match = FILENAME_PATTERN.match(filename) + if not match: + return None, ( + f"Invalid filename '{filename}'. " + f"Expected format: -.yaml " + f"(e.g., 1234-add-user-endpoint.yaml)" + ) + + # Validate filename PR number matches the pr field + filename_pr = int(match.group(1)) + if filename_pr != data["pr"]: + return None, ( + f"PR number mismatch in {filename}: " + f"filename has {filename_pr} but pr field is {data['pr']}" + ) + + # Validate labels are recognized + labels = data.get("labels", []) + if labels and not isinstance(labels, list): + return None, ( + f"'labels' must be a list in {filename} (e.g., [high-risk, db-migration]), " + f"got: {labels!r}" + ) + if labels: + invalid_labels = [label for label in labels if label not in LABEL_URLS] + if invalid_labels: + valid_labels = ", ".join(sorted(LABEL_URLS.keys())) + return None, ( + f"Invalid label(s) in {filename}: {', '.join(invalid_labels)}. " + f"Valid labels are: {valid_labels}" + ) + entry = ChangelogEntry( entry_type=entry_type, - description=data["description"], + description=description, pr=data.get("pr"), - labels=data.get("labels", []), + labels=labels, ) return entry, None -def load_fragments() -> list[tuple[Path, ChangelogEntry]]: - """Load all changelog fragment files and return list of (path, entry) tuples.""" +def _load_and_validate_file( + path: Path, +) -> tuple[Optional[ChangelogEntry], Optional[str]]: + """ + Load and validate a single changelog fragment file. + + Args: + path: Path to the YAML fragment file + + Returns: + Tuple of (ChangelogEntry or None, error message or None) + """ import yaml # Import here since it's installed by the nox session + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + except Exception as e: + return None, f"Error parsing {path.name}: {e}" + + return validate_fragment_data(data, path.name) + + +def load_fragments() -> list[tuple[Path, ChangelogEntry]]: + """Load all changelog fragment files and return list of (path, entry) tuples.""" if not CHANGELOG_DIR.exists(): return [] - entries = [] - errors = [] # Look for both .yaml and .yml files yaml_files = list(CHANGELOG_DIR.glob("*.yaml")) + list(CHANGELOG_DIR.glob("*.yml")) - for yaml_file in yaml_files: - if yaml_file.name == TEMPLATE_FILE_NAME: - continue + file_paths = [str(f) for f in yaml_files if f.name != TEMPLATE_FILE_NAME] - try: - with open(yaml_file, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - entry, error = validate_fragment_data(data, yaml_file.name) - if error: - errors.append(error) - continue - if entry: - entries.append((yaml_file, entry)) - except Exception as e: - errors.append(f"Error parsing {yaml_file.name}: {e}") - - if errors: - error_msg = "Found errors in changelog fragments:\n" + "\n".join( - f" - {err}" for err in errors - ) - raise ValueError(error_msg) - - return entries + return validate_fragment_files(file_paths) def validate_fragment_files( @@ -157,8 +209,6 @@ def validate_fragment_files( Raises: ValueError: If any validation errors are found """ - import yaml # Import here since it's installed by the nox session - entries = [] errors = [] @@ -186,18 +236,12 @@ def validate_fragment_files( ) continue - try: - with open(path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - entry, error = validate_fragment_data(data, path.name) - if error: - errors.append(error) - continue - if entry: - entries.append((path, entry)) - except Exception as e: - errors.append(f"Error parsing {path.name}: {e}") + entry, error = _load_and_validate_file(path) + if error: + errors.append(error) + continue + if entry: + entries.append((path, entry)) if errors: error_msg = "Found errors in changelog fragments:\n" + "\n".join( @@ -239,6 +283,49 @@ def generate_changelog_section(grouped_entries: dict[str, list[ChangelogEntry]]) return "\n".join(lines).rstrip() +def _find_unreleased_section_with_version( + content: str, +) -> tuple[int, int, str]: + """ + Find the Unreleased section boundaries and the previous version. + + Args: + content: The full CHANGELOG.md content + + Returns: + Tuple of (unreleased_start, unreleased_end, prev_version) + + Raises: + ValueError: If Unreleased section or previous version not found + """ + lines = content.split("\n") + unreleased_start = None + unreleased_end = None + prev_version = None + + for i, line in enumerate(lines): + if line.startswith("## [Unreleased]"): + unreleased_start = i + for j in range(i + 1, len(lines)): + if lines[j].startswith("## ["): + unreleased_end = j + match = re.search(r"## \[([\d.]+)\]", lines[j]) + if match: + prev_version = match.group(1) + break + if unreleased_end is None: + unreleased_end = len(lines) + break + + if unreleased_start is None: + raise ValueError("Could not find [Unreleased] section in CHANGELOG.md") + + if prev_version is None: + raise ValueError("Could not find previous version for compare link") + + return unreleased_start, unreleased_end, prev_version + + def find_unreleased_section(content: str) -> tuple[int, int]: """Find the start and end line numbers of the Unreleased section.""" lines = content.split("\n") @@ -283,33 +370,9 @@ def insert_entries_into_changelog(content: str, new_entries: str) -> str: def finalize_release(content: str, version: str) -> str: """Create a new version section from Unreleased content and leave Unreleased empty.""" lines = content.split("\n") - - # Find Unreleased section boundaries - unreleased_start = None - unreleased_end = None - prev_version = None - - for i, line in enumerate(lines): - if line.startswith("## [Unreleased]"): - unreleased_start = i - # Find the end of Unreleased section (next release section) - for j in range(i + 1, len(lines)): - if lines[j].startswith("## ["): - unreleased_end = j - # Extract version from next release line for compare link - match = re.search(r"## \[([\d.]+)\]", lines[j]) - if match: - prev_version = match.group(1) - break - if unreleased_end is None: - unreleased_end = len(lines) - break - - if unreleased_start is None: - raise ValueError("Could not find [Unreleased] section in CHANGELOG.md") - - if prev_version is None: - raise ValueError("Could not find previous version for compare link") + unreleased_start, unreleased_end, prev_version = ( + _find_unreleased_section_with_version(content) + ) # Extract Unreleased content (skip the header line) unreleased_content = lines[unreleased_start + 1 : unreleased_end] @@ -350,33 +413,9 @@ def finalize_patch_release(content: str, version: str, new_section: str) -> str: not all the Unreleased content. """ lines = content.split("\n") - - # Find Unreleased section boundaries - unreleased_start = None - unreleased_end = None - prev_version = None - - for i, line in enumerate(lines): - if line.startswith("## [Unreleased]"): - unreleased_start = i - # Find the end of Unreleased section (next release section) - for j in range(i + 1, len(lines)): - if lines[j].startswith("## ["): - unreleased_end = j - # Extract version from next release line for compare link - match = re.search(r"## \[([\d.]+)\]", lines[j]) - if match: - prev_version = match.group(1) - break - if unreleased_end is None: - unreleased_end = len(lines) - break - - if unreleased_start is None: - raise ValueError("Could not find [Unreleased] section in CHANGELOG.md") - - if prev_version is None: - raise ValueError("Could not find previous version for compare link") + unreleased_start, unreleased_end, prev_version = ( + _find_unreleased_section_with_version(content) + ) # Build new content: # 1. Everything before Unreleased section (including Unreleased header and existing content) @@ -417,6 +456,19 @@ def _handle_validate(session: nox.Session) -> None: else: session.error("--files flag requires a comma-separated list of file paths") + # Check for PR number flag + expected_pr_number = None + if "--pr-number" in session.posargs: + pr_idx = session.posargs.index("--pr-number") + if pr_idx + 1 < len(session.posargs): + try: + expected_pr_number = int(session.posargs[pr_idx + 1]) + except ValueError: + session.error("--pr-number flag requires an integer PR number") + else: + session.error("--pr-number flag requires a PR number") + + validated: list = [] try: if files_filter: # Validate specific files @@ -436,6 +488,21 @@ def _handle_validate(session: nox.Session) -> None: except ValueError as e: session.error(str(e)) + # Validate PR number matches the current PR + if expected_pr_number is not None and validated: + mismatched = [ + (path, entry) for path, entry in validated if entry.pr != expected_pr_number + ] + if mismatched: + error_lines = [ + f"Changelog entry PR number does not match the current PR (#{expected_pr_number}):" + ] + for path, entry in mismatched: + error_lines.append( + f" - {path.name}: has pr: {entry.pr}, expected pr: {expected_pr_number}" + ) + session.error("\n".join(error_lines)) + def _handle_dry( session: nox.Session, @@ -687,18 +754,6 @@ def changelog(session: nox.Session, action: str) -> None: if not fragment_data: return - # Validate all entries have valid types (double-check) - invalid_entries = [ - (path, entry) - for path, entry in fragment_data - if entry.type not in CHANGELOG_TYPES - ] - if invalid_entries: - error_msg = "Found entries with invalid types:\n" - for path, entry in invalid_entries: - error_msg += f" - {path.name}: type '{entry.type}' is not valid. Must be one of: {', '.join(CHANGELOG_TYPES)}\n" - session.error(error_msg) - # Generate the changelog section grouped = group_entries_by_type(list(entries)) new_section = generate_changelog_section(grouped) From 622339f30c19cc40121d1f43fab415da068b3e97 Mon Sep 17 00:00:00 2001 From: Tina Zimnicki Date: Fri, 13 Mar 2026 13:25:24 -0700 Subject: [PATCH 11/44] ENG-2338 - Configure when Banners resurface (#7292) Co-authored-by: Tom Van Dort --- .fides/db_dataset.yml | 6 + changelog/7292.yaml | 4 + .../PrivacyExperienceForm.tsx | 63 ++++++ clients/admin-ui/src/types/api/index.ts | 1 + .../api/models/ExperienceConfigCreate.ts | 2 + .../api/models/ExperienceConfigUpdate.ts | 2 + .../src/types/api/models/ResurfaceBehavior.ts | 12 ++ .../__tests__/lib/consent-utils.test.ts | 143 ++++++++++++++ .../docs/interfaces/FidesExperienceConfig.md | 19 ++ .../src/docs/fides-experience-config.ts | 14 ++ clients/fides-js/src/lib/consent-utils.ts | 9 + ...7_add_resurface_behavior_to_experience_.py | 54 ++++++ src/fides/api/models/privacy_experience.py | 50 +++++ tests/api/models/test_privacy_experience.py | 183 ++++++++++++++++++ 14 files changed, 562 insertions(+) create mode 100644 changelog/7292.yaml create mode 100644 clients/admin-ui/src/types/api/models/ResurfaceBehavior.ts create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_01_13_1934_ea20059aee77_add_resurface_behavior_to_experience_.py diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index 4b8dab1ffbf..695b2900a89 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -1869,6 +1869,8 @@ dataset: data_categories: [ system.operations ] - name: asset_disclosure_include_types data_categories: [ system.operations ] + - name: resurface_behavior + data_categories: [ system.operations ] - name: privacyexperienceconfighistory description: 'Historical table to store all versions of Experience Config History for record keeping' fields: @@ -1954,6 +1956,8 @@ dataset: data_categories: [ system.operations ] - name: asset_disclosure_include_types data_categories: [ system.operations ] + - name: resurface_behavior + data_categories: [ system.operations ] - name: privacynoticetemplate data_categories: [] fields: @@ -2973,6 +2977,8 @@ dataset: data_categories: [ system.operations ] - name: asset_disclosure_include_types data_categories: [ system.operations ] + - name: resurface_behavior + data_categories: [ system.operations ] - name: experiencenotices description: 'The table that links Privacy Notices to Experience Configs (many-to-many)' fields: diff --git a/changelog/7292.yaml b/changelog/7292.yaml new file mode 100644 index 00000000000..a086e2a6866 --- /dev/null +++ b/changelog/7292.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added resurface_behavior configuration to privacy experience configs to control when consent banners are reshown after user interaction +pr: 7292 +labels: ["db-migration"] diff --git a/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx b/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx index 571ddda77fa..a8d90a18d1d 100644 --- a/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx +++ b/clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx @@ -3,10 +3,13 @@ import { Button, ChakraArrowForwardIcon as ArrowForwardIcon, ChakraBox as Box, + ChakraCheckbox as Checkbox, + ChakraCheckboxGroup as CheckboxGroup, ChakraDivider as Divider, ChakraFlex as Flex, ChakraFormLabel as FormLabel, ChakraHeading as Heading, + ChakraStack as Stack, ChakraText as Text, formatIsoLocation, isoStringToEntry, @@ -50,6 +53,7 @@ import { PrivacyNoticeFramework, Property, RejectAllMechanism, + ResurfaceBehavior, StagedResourceTypeValue, SupportedLanguage, } from "~/types/api"; @@ -96,6 +100,19 @@ const tcfRejectAllMechanismOptions: SelectProps["options"] = [ }, ]; +const resurfaceBehaviorOptions = [ + { + label: "Reject", + value: ResurfaceBehavior.REJECT, + description: "Show the banner again when user rejects", + }, + { + label: "Dismiss", + value: ResurfaceBehavior.DISMISS, + description: "Show the banner again when user dismisses", + }, +]; + const tcfBannerButtonOptions: SelectProps["options"] = [ { label: "Banner and modal", @@ -449,6 +466,52 @@ export const PrivacyExperienceForm = ({ /> )} + {(values.component === ComponentType.BANNER_AND_MODAL || + values.component === ComponentType.TCF_OVERLAY) && ( + + + Resurface banner + + + Choose when to show the banner again after the user has interacted + with it. Leave unchecked for default behavior (only resurface on + cookie expiration, vendor changes, and other mandatory updates.) + + { + setFieldValue( + "resurface_behavior", + selectedValues.length > 0 ? selectedValues : null, + ); + }} + > + + {resurfaceBehaviorOptions.map((option) => { + const isDisabled = + option.value === ResurfaceBehavior.DISMISS && + !values.dismissable; + return ( + + + + {option.label} + + + {option.description} + + + + ); + })} + + + + )} Privacy notices diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index cf6fa34f17d..7ccd404d270 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -534,6 +534,7 @@ export type { ResourceFilter } from "./models/ResourceFilter"; export { ResourceTypes } from "./models/ResourceTypes"; export { ResponseFormat } from "./models/ResponseFormat"; export type { ResponseWithMessage } from "./models/ResponseWithMessage"; +export { ResurfaceBehavior } from "./models/ResurfaceBehavior"; export type { RevertAnswerRequest } from "./models/RevertAnswerRequest"; export type { ReviewPrivacyRequestIds } from "./models/ReviewPrivacyRequestIds"; export { RoleRegistryEnum } from "./models/RoleRegistryEnum"; diff --git a/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts b/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts index f292a9b8cda..ac0f209bf83 100644 --- a/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts +++ b/clients/admin-ui/src/types/api/models/ExperienceConfigCreate.ts @@ -8,6 +8,7 @@ import type { Layer1ButtonOption } from "./Layer1ButtonOption"; import type { MinimalProperty } from "./MinimalProperty"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; import type { RejectAllMechanism } from "./RejectAllMechanism"; +import type { ResurfaceBehavior } from "./ResurfaceBehavior"; /** * Schema for creating Experience Configs via the API @@ -35,6 +36,7 @@ export type ExperienceConfigCreate = { * Determines the behavior of the reject all button */ reject_all_mechanism?: RejectAllMechanism | null; + resurface_behavior?: Array | null; privacy_notice_ids?: Array; translations?: Array; properties?: Array; diff --git a/clients/admin-ui/src/types/api/models/ExperienceConfigUpdate.ts b/clients/admin-ui/src/types/api/models/ExperienceConfigUpdate.ts index 09f06012892..4b8968f6a39 100644 --- a/clients/admin-ui/src/types/api/models/ExperienceConfigUpdate.ts +++ b/clients/admin-ui/src/types/api/models/ExperienceConfigUpdate.ts @@ -7,6 +7,7 @@ import type { Layer1ButtonOption } from "./Layer1ButtonOption"; import type { MinimalProperty } from "./MinimalProperty"; import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion"; import type { RejectAllMechanism } from "./RejectAllMechanism"; +import type { ResurfaceBehavior } from "./ResurfaceBehavior"; /** * The schema to update an ExperienceConfig via the API. @@ -41,4 +42,5 @@ export type ExperienceConfigUpdate = { * Determines the behavior of the reject all button */ reject_all_mechanism?: RejectAllMechanism | null; + resurface_behavior?: Array | null; }; diff --git a/clients/admin-ui/src/types/api/models/ResurfaceBehavior.ts b/clients/admin-ui/src/types/api/models/ResurfaceBehavior.ts new file mode 100644 index 00000000000..53a8a46937a --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ResurfaceBehavior.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Resurface behavior options - controls when to re-show the banner/modal. + * Used to configure whether the experience resurfaces after rejection or dismissal. + */ +export enum ResurfaceBehavior { + REJECT = "reject", + DISMISS = "dismiss", +} diff --git a/clients/fides-js/__tests__/lib/consent-utils.test.ts b/clients/fides-js/__tests__/lib/consent-utils.test.ts index 4004c53986c..99db63a170d 100644 --- a/clients/fides-js/__tests__/lib/consent-utils.test.ts +++ b/clients/fides-js/__tests__/lib/consent-utils.test.ts @@ -439,6 +439,149 @@ describe("shouldResurfaceBanner", () => { options: {}, expected: false, }, + { + label: + "returns true when user rejected and resurface_behavior includes reject", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: ["reject"], + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.REJECT }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: true, + }, + { + label: + "returns true when user dismissed and resurface_behavior includes dismiss", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: ["dismiss"], + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.DISMISS }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: true, + }, + { + label: + "returns true when user rejected and resurface_behavior includes both reject and dismiss", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: ["reject", "dismiss"], + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.REJECT }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: true, + }, + { + label: + "returns false when user accepted and resurface_behavior only includes reject", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: ["reject"], + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.ACCEPT }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: false, + }, + { + label: "returns false when user rejected and resurface_behavior is null", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: null, + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.REJECT }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: false, + }, + { + label: + "returns false when user rejected and resurface_behavior is undefined", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: undefined, + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.REJECT }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: false, + }, + { + label: + "returns false when user dismissed and resurface_behavior only includes reject", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: ["reject"], + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.DISMISS }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: false, + }, + { + label: + "returns true when user dismissed and resurface_behavior includes both", + experience: { + ...mockExperience, + experience_config: { + component: ComponentType.BANNER_AND_MODAL, + resurface_behavior: ["reject", "dismiss"], + }, + }, + cookie: { + ...mockCookie, + fides_meta: { consentMethod: ConsentMethod.DISMISS }, + }, + savedConsent: mockSavedConsent, + options: {}, + expected: true, + }, ])("$label", ({ experience, cookie, savedConsent, options, expected }) => { expect( shouldResurfaceBanner( diff --git a/clients/fides-js/docs/interfaces/FidesExperienceConfig.md b/clients/fides-js/docs/interfaces/FidesExperienceConfig.md index 842de1ac189..b2e3a4977f1 100644 --- a/clients/fides-js/docs/interfaces/FidesExperienceConfig.md +++ b/clients/fides-js/docs/interfaces/FidesExperienceConfig.md @@ -123,3 +123,22 @@ List of all available translations for the current experience. This corresponds with the "Reject all mechanism" configuration option for TCF overlay experiences. Determines whether opting out of all purposes blocks everything (both consent and legitimate interest processing) or only blocks consent-based processing while allowing legitimate interest to continue. + +*** + +### resurface\_behavior? + +> `optional` **resurface\_behavior**: `string`[] + +This corresponds with the "Resurface banner" configuration option. +Controls when to show the consent banner again after the user has interacted with it. +Can include "reject", "dismiss", both, or be empty/null for default behavior (only resurface on cookie expiration or vendor changes). + +#### Example + +```ts +["reject"] // Resurface only on reject +["dismiss"] // Resurface only on dismiss +["reject", "dismiss"] // Resurface on both +null // Default behavior (no resurfacing) +``` diff --git a/clients/fides-js/src/docs/fides-experience-config.ts b/clients/fides-js/src/docs/fides-experience-config.ts index 9fdbcd61cc7..75dd3e3dc79 100644 --- a/clients/fides-js/src/docs/fides-experience-config.ts +++ b/clients/fides-js/src/docs/fides-experience-config.ts @@ -87,6 +87,20 @@ export interface FidesExperienceConfig { */ reject_all_mechanism: string; + /** + * This corresponds with the "Resurface banner" configuration option. + * Controls when to show the consent banner again after the user has interacted with it. + * Can include "reject", "dismiss", both, or be empty/null for default behavior (only resurface on cookie expiration or vendor changes). + * @example + * ```ts + * ["reject"] // Resurface only on reject + * ["dismiss"] // Resurface only on dismiss + * ["reject", "dismiss"] // Resurface on both + * null // Default behavior (no resurfacing) + * ``` + */ + resurface_behavior?: Array; + /** * @internal */ diff --git a/clients/fides-js/src/lib/consent-utils.ts b/clients/fides-js/src/lib/consent-utils.ts index 7bdb4f2d8c4..49623cc212b 100644 --- a/clients/fides-js/src/lib/consent-utils.ts +++ b/clients/fides-js/src/lib/consent-utils.ts @@ -311,6 +311,15 @@ export const shouldResurfaceBanner = ( if (cookie?.fides_meta.consentMethod === ConsentMethod.GPC) { return true; } + // Resurface if configured for this consent method + if ( + cookie?.fides_meta.consentMethod && + experience.experience_config?.resurface_behavior?.includes( + cookie.fides_meta.consentMethod, + ) + ) { + return true; + } // Lastly, if we do have a prior consent state, resurface if we find *any* // notices that don't have prior consent in that state const hasConsentInCookie = ( diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_13_1934_ea20059aee77_add_resurface_behavior_to_experience_.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_13_1934_ea20059aee77_add_resurface_behavior_to_experience_.py new file mode 100644 index 00000000000..1067f4036e9 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_13_1934_ea20059aee77_add_resurface_behavior_to_experience_.py @@ -0,0 +1,54 @@ +"""add resurface_behavior to experience config + +Revision ID: ea20059aee77 +Revises: e3a9f1b2c4d5 +Create Date: 2026-01-13 19:34:06.296198 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ea20059aee77' +down_revision = '4ac4864180db' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add resurface_behavior array column to all three experience config tables + # Non-nullable with empty array default for config tables, nullable for history + op.add_column( + "experienceconfigtemplate", + sa.Column( + "resurface_behavior", + sa.ARRAY(sa.String()), + nullable=False, + server_default="{}", + ), + ) + op.add_column( + "privacyexperienceconfig", + sa.Column( + "resurface_behavior", + sa.ARRAY(sa.String()), + nullable=False, + server_default="{}", + ), + ) + op.add_column( + "privacyexperienceconfighistory", + sa.Column( + "resurface_behavior", + sa.ARRAY(sa.String()), + nullable=True, + ), + ) + + +def downgrade(): + # Remove resurface_behavior column from all three tables (reverse order) + op.drop_column("privacyexperienceconfighistory", "resurface_behavior") + op.drop_column("privacyexperienceconfig", "resurface_behavior") + op.drop_column("experienceconfigtemplate", "resurface_behavior") diff --git a/src/fides/api/models/privacy_experience.py b/src/fides/api/models/privacy_experience.py index baf9c71b7ee..832a9cf6086 100644 --- a/src/fides/api/models/privacy_experience.py +++ b/src/fides/api/models/privacy_experience.py @@ -65,6 +65,19 @@ class RejectAllMechanism(Enum): REJECT_CONSENT_ONLY = "reject_consent_only" +class ResurfaceBehavior(Enum): + """ + Resurface behavior options - controls when to re-show the banner/modal. + Used to configure whether the experience resurfaces after rejection or dismissal. + """ + + # Resurface the banner after user rejects + REJECT = "reject" + # Resurface the banner after user dismisses + DISMISS = "dismiss" + # Note: NULL or empty array means default behavior (no resurfacing) + + # Fides JS UX Types - there should only be one of these defined per region FidesJSUXTypes: List[ComponentType] = [ ComponentType.banner_and_modal, @@ -108,6 +121,19 @@ class PrivacyExperienceConfigBase: default=True, ) # base is nullable for privacy experience config history + resurface_behavior = Column( + ARRAY( + EnumColumn( + ResurfaceBehavior, + native_enum=False, + values_callable=lambda x: [i.value for i in x], + ) + ), + nullable=True, + server_default="{}", + default=list, + ) + disabled = Column(Boolean, nullable=False, default=True) dismissable = Column(Boolean) @@ -161,6 +187,18 @@ class ExperienceConfigTemplate(PrivacyExperienceConfigBase, Base): name = Column( String, nullable=False ) # Overriding PrivacyExperienceConfigBase to make non-nullable + resurface_behavior = Column( + ARRAY( + EnumColumn( + ResurfaceBehavior, + native_enum=False, + values_callable=lambda x: [i.value for i in x], + ) + ), + nullable=False, + server_default="{}", + default=list, + ) # Overrides PrivacyExperienceConfigBase to make non-nullable privacy_notice_keys = Column( ARRAY(String) @@ -248,6 +286,18 @@ class PrivacyExperienceConfig(PrivacyExperienceConfigBase, Base): name = Column( String, nullable=False ) # Overriding PrivacyExperienceConfigBase to make non-nullable + resurface_behavior = Column( + ARRAY( + EnumColumn( + ResurfaceBehavior, + native_enum=False, + values_callable=lambda x: [i.value for i in x], + ) + ), + nullable=False, + server_default="{}", + default=list, + ) # Overrides PrivacyExperienceConfigBase to make non-nullable origin = Column( String, ForeignKey(ExperienceConfigTemplate.id_field_path) ) # The template from which this config was created if applicable diff --git a/tests/api/models/test_privacy_experience.py b/tests/api/models/test_privacy_experience.py index 0a05f6ad577..0dda0792678 100644 --- a/tests/api/models/test_privacy_experience.py +++ b/tests/api/models/test_privacy_experience.py @@ -12,6 +12,7 @@ PrivacyExperienceConfig, PrivacyExperienceConfigHistory, RejectAllMechanism, + ResurfaceBehavior, link_notices_to_experience_config, upsert_privacy_experiences_after_config_update, ) @@ -680,6 +681,188 @@ def test_update_privacy_experience_config_update_tcf_configuration( db.refresh(experience_config_tcf_overlay) assert experience_config_tcf_overlay.tcf_configuration is None + def test_create_privacy_experience_config_with_resurface_behavior( + self, db, privacy_notice + ): + """Test that PrivacyExperienceConfig can be created with resurface_behavior""" + config = PrivacyExperienceConfig.create( + db=db, + data={ + "component": "banner_and_modal", + "regions": [PrivacyNoticeRegion.us_ca], + "privacy_notice_ids": [privacy_notice.id], + "name": "Test Banner with Resurface", + "resurface_behavior": [ + ResurfaceBehavior.REJECT, + ResurfaceBehavior.DISMISS, + ], + "translations": [ + { + "language": "en", + "description": "We care about your privacy.", + "privacy_preferences_link_label": "Manage preferences", + "modal_link_label": "Manage my consent preferences", + "privacy_policy_link_label": "View our privacy policy", + "privacy_policy_url": "http://example.com/privacy", + "reject_button_label": "Reject all", + "save_button_label": "Save", + "title": "Control your privacy", + "accept_button_label": "Accept all", + "acknowledge_button_label": "OK", + "banner_description": "You can accept, reject, or manage your preferences.", + "banner_title": "Control Your Privacy", + "is_default": True, + } + ], + }, + ) + + assert config.resurface_behavior == [ + ResurfaceBehavior.REJECT, + ResurfaceBehavior.DISMISS, + ] + + # Verify it's stored in the history as well + history = config.translations[0].histories[0] + assert history.resurface_behavior == [ + ResurfaceBehavior.REJECT, + ResurfaceBehavior.DISMISS, + ] + + for translation in config.translations: + for history in translation.histories: + history.delete(db) + translation.delete(db) + config.delete(db) + + def test_create_privacy_experience_config_with_single_resurface_behavior(self, db): + """Test that resurface_behavior can be set with a single value""" + config = PrivacyExperienceConfig.create( + db=db, + data={ + "component": "banner_and_modal", + "name": "Test Banner Reject Only", + "resurface_behavior": [ResurfaceBehavior.REJECT], + "translations": [ + { + "language": "en", + "description": "Privacy description", + "privacy_preferences_link_label": "Manage", + "modal_link_label": "Manage", + "privacy_policy_link_label": "Policy", + "privacy_policy_url": "http://example.com", + "reject_button_label": "Reject", + "save_button_label": "Save", + "title": "Privacy", + "accept_button_label": "Accept", + "acknowledge_button_label": "OK", + "banner_description": "Description", + "banner_title": "Title", + "is_default": True, + } + ], + }, + ) + + assert config.resurface_behavior == [ResurfaceBehavior.REJECT] + + for translation in config.translations: + for history in translation.histories: + history.delete(db) + translation.delete(db) + config.delete(db) + + def test_create_privacy_experience_config_without_resurface_behavior(self, db): + """Test that resurface_behavior defaults to empty list""" + config = PrivacyExperienceConfig.create( + db=db, + data={ + "component": "banner_and_modal", + "name": "Test Banner No Resurface", + "translations": [ + { + "language": "en", + "description": "Privacy description", + "privacy_preferences_link_label": "Manage", + "modal_link_label": "Manage", + "privacy_policy_link_label": "Policy", + "privacy_policy_url": "http://example.com", + "reject_button_label": "Reject", + "save_button_label": "Save", + "title": "Privacy", + "accept_button_label": "Accept", + "acknowledge_button_label": "OK", + "banner_description": "Description", + "banner_title": "Title", + "is_default": True, + } + ], + }, + ) + + assert config.resurface_behavior == [] + + for translation in config.translations: + for history in translation.histories: + history.delete(db) + translation.delete(db) + config.delete(db) + + def test_update_privacy_experience_config_resurface_behavior(self, db): + """Test that resurface_behavior can be updated""" + config = PrivacyExperienceConfig.create( + db=db, + data={ + "component": "banner_and_modal", + "name": "Test Update Resurface", + "resurface_behavior": [ResurfaceBehavior.REJECT], + "translations": [ + { + "language": "en", + "description": "Privacy description", + "privacy_preferences_link_label": "Manage", + "modal_link_label": "Manage", + "privacy_policy_link_label": "Policy", + "privacy_policy_url": "http://example.com", + "reject_button_label": "Reject", + "save_button_label": "Save", + "title": "Privacy", + "accept_button_label": "Accept", + "acknowledge_button_label": "OK", + "banner_description": "Description", + "banner_title": "Title", + "is_default": True, + } + ], + }, + ) + + assert config.resurface_behavior == [ResurfaceBehavior.REJECT] + + # Update to include both + config.resurface_behavior = [ + ResurfaceBehavior.REJECT, + ResurfaceBehavior.DISMISS, + ] + config.save(db) + + assert config.resurface_behavior == [ + ResurfaceBehavior.REJECT, + ResurfaceBehavior.DISMISS, + ] + + # Reset to empty list (column is non-nullable) + config.resurface_behavior = [] + config.save(db) + + assert config.resurface_behavior == [] + + for translation in config.translations: + for history in translation.histories: + history.delete(db) + translation.delete(db) + config.delete(db) + class TestPrivacyExperience: def test_create_privacy_experience(self, db, experience_config_banner_and_modal): From f08b6136711ab01c40062187ca3f02a82117f6c5 Mon Sep 17 00:00:00 2001 From: Eliana Rosselli <67162025+erosselli@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:34:16 -0300 Subject: [PATCH 12/44] ENG-2857 Use get_encryption_key callable in all places (#7652) --- src/fides/api/oauth/utils.py | 21 ++++++------------- src/fides/api/v1/endpoints/oauth_endpoints.py | 3 ++- src/fides/api/v1/endpoints/user_endpoints.py | 7 +++---- src/fides/service/user/user_service.py | 3 ++- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/fides/api/oauth/utils.py b/src/fides/api/oauth/utils.py index a8ce7a12522..599b6c0fb58 100644 --- a/src/fides/api/oauth/utils.py +++ b/src/fides/api/oauth/utils.py @@ -27,6 +27,7 @@ JWE_PAYLOAD_SCOPES, ) from fides.api.db.ctl_session import get_async_db +from fides.api.db.encryption_utils import get_encryption_key from fides.api.deps import get_db from fides.api.models.client import ClientDetail from fides.api.models.fides_user import FidesUser @@ -249,9 +250,7 @@ def _get_webhook_jwe_or_error( raise AuthenticationError(detail="Authentication Failure") try: - token_data = json.loads( - extract_payload(authorization, CONFIG.security.app_encryption_key) - ) + token_data = json.loads(extract_payload(authorization, get_encryption_key())) except JoseError: raise AuthorizationError(detail="Not Authorized for this action") @@ -277,9 +276,7 @@ def _get_request_task_jwe_or_error( raise AuthenticationError(detail="Authentication Failure") try: - token_data = json.loads( - extract_payload(authorization, CONFIG.security.app_encryption_key) - ) + token_data = json.loads(extract_payload(authorization, get_encryption_key())) except JoseError: raise AuthorizationError(detail="Not Authorized for this action") @@ -321,9 +318,7 @@ def validate_download_token(token: str, privacy_request_id: str) -> DownloadToke raise AuthenticationError(detail="Invalid download token format") try: - token_data = json.loads( - extract_payload(token, CONFIG.security.app_encryption_key) - ) + token_data = json.loads(extract_payload(token, get_encryption_key())) except JoseError: raise AuthenticationError(detail="Invalid download token format") @@ -559,9 +554,7 @@ def extract_token_and_load_client( raise AuthenticationError(detail="Authentication Failure") try: - token_data = json.loads( - extract_payload(authorization, CONFIG.security.app_encryption_key) - ) + token_data = json.loads(extract_payload(authorization, get_encryption_key())) except (JoseError, ValueError) as exc: logger.debug("Unable to parse auth token.") raise AuthorizationError(detail="Not Authorized for this action") from exc @@ -632,9 +625,7 @@ async def extract_token_and_load_client_async( raise AuthenticationError(detail="Authentication Failure") try: - token_data = json.loads( - extract_payload(authorization, CONFIG.security.app_encryption_key) - ) + token_data = json.loads(extract_payload(authorization, get_encryption_key())) except (JoseError, ValueError) as exc: logger.debug("Unable to parse auth token.") raise AuthorizationError(detail="Not Authorized for this action") from exc diff --git a/src/fides/api/v1/endpoints/oauth_endpoints.py b/src/fides/api/v1/endpoints/oauth_endpoints.py index f1f3754bd62..17d52b870b8 100644 --- a/src/fides/api/v1/endpoints/oauth_endpoints.py +++ b/src/fides/api/v1/endpoints/oauth_endpoints.py @@ -18,6 +18,7 @@ FidesopsException, OAuth2TokenException, ) +from fides.api.db.encryption_utils import get_encryption_key from fides.api.deps import get_db from fides.api.models.authentication_request import AuthenticationRequest from fides.api.models.client import ClientDetail @@ -112,7 +113,7 @@ async def acquire_access_token( expire_minutes = CONFIG.security.oauth_access_token_expire_minutes access_code = client_detail.create_access_code_jwe( - CONFIG.security.app_encryption_key, + get_encryption_key(), token_expire_minutes=expire_minutes, ) diff --git a/src/fides/api/v1/endpoints/user_endpoints.py b/src/fides/api/v1/endpoints/user_endpoints.py index 9ff850173df..a54d85715be 100644 --- a/src/fides/api/v1/endpoints/user_endpoints.py +++ b/src/fides/api/v1/endpoints/user_endpoints.py @@ -29,6 +29,7 @@ from fides.api.common_exceptions import AuthenticationError from fides.api.cryptography.cryptographic_util import b64_str_to_str from fides.api.cryptography.schemas.jwt import JWE_PAYLOAD_CLIENT_ID +from fides.api.db.encryption_utils import get_encryption_key from fides.api.deps import get_config_proxy, get_db, get_user_service from fides.api.models.client import ClientDetail from fides.api.models.fides_user import FidesUser @@ -290,9 +291,7 @@ def logout_oauth_client( return None try: - token_data = json.loads( - extract_payload(authorization, CONFIG.security.app_encryption_key) - ) + token_data = json.loads(extract_payload(authorization, get_encryption_key())) except JoseError: return None @@ -756,7 +755,7 @@ def user_login( logger.info("Creating login access token") expire_minutes = config.security.oauth_access_token_expire_minutes access_code = client.create_access_code_jwe( - config.security.app_encryption_key, + get_encryption_key(), token_expire_minutes=expire_minutes, ) diff --git a/src/fides/service/user/user_service.py b/src/fides/service/user/user_service.py index 354582b9548..9d5a7b1a982 100644 --- a/src/fides/service/user/user_service.py +++ b/src/fides/service/user/user_service.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from fides.api.common_exceptions import AuthorizationError +from fides.api.db.encryption_utils import get_encryption_key from fides.api.models.client import ClientDetail from fides.api.models.fides_user import FidesUser from fides.api.models.fides_user_invite import FidesUserInvite @@ -142,7 +143,7 @@ def accept_invite( logger.info("Creating login access token") access_code = client.create_access_code_jwe( - self.config.security.app_encryption_key, + get_encryption_key(), token_expire_minutes=self.config.security.oauth_access_token_expire_minutes, ) From 7d28b6f951de4f90c649dcf1349cbf23318d78e6 Mon Sep 17 00:00:00 2001 From: Jason Gill Date: Mon, 16 Mar 2026 09:22:37 -0600 Subject: [PATCH 13/44] ENG-2908: Serve IAB TCF CMP stub as fides-stub.js from Privacy Center (#7642) --- changelog/7642-serve-iab-tcf-stub.yaml | 4 + clients/fides-js/package.json | 1 + clients/fides-js/rollup.config.mjs | 42 +++++++ clients/package-lock.json | 118 +++++++++++++----- clients/privacy-center/next.config.js | 6 + .../privacy-center/public/fides-js-demo.html | 1 + 6 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 changelog/7642-serve-iab-tcf-stub.yaml diff --git a/changelog/7642-serve-iab-tcf-stub.yaml b/changelog/7642-serve-iab-tcf-stub.yaml new file mode 100644 index 00000000000..95f63eeb822 --- /dev/null +++ b/changelog/7642-serve-iab-tcf-stub.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added IAB TCF CMP stub served as /fides-stub.js from Privacy Center +pr: 7642 +labels: [] diff --git a/clients/fides-js/package.json b/clients/fides-js/package.json index 45556cff56e..4559fa3589f 100644 --- a/clients/fides-js/package.json +++ b/clients/fides-js/package.json @@ -63,6 +63,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "@iabtechlabtcf/stub": "1.5.21", "@rollup/plugin-alias": "^5.0.0", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.0.0", diff --git a/clients/fides-js/rollup.config.mjs b/clients/fides-js/rollup.config.mjs index 47c3ece77e6..bfbd36ef2b6 100644 --- a/clients/fides-js/rollup.config.mjs +++ b/clients/fides-js/rollup.config.mjs @@ -256,6 +256,48 @@ const previewScript = { // Add preview script to build after the main scripts rollupOptions.push(previewScript); +/** + * Copy the official IAB TCF CMP stub to privacy-center as a static file. + * Publishers load this before fides.js so that window.__tcfapi is available + * immediately for downstream ad scripts. + * + * Uses a virtual module as input since there is no source to compile — we + * only need rollup to run the copy plugin. + */ +const copyStub = { + input: "\0virtual-stub", + plugins: [ + { + name: "virtual-stub", + resolveId(id) { + if (id === "\0virtual-stub") return id; + }, + load(id) { + if (id === "\0virtual-stub") return "// IAB TCF stub copy-only entry"; + }, + }, + copy({ + targets: [ + { + src: "../node_modules/@iabtechlabtcf/stub/lib/stub.js", + dest: "../privacy-center/public/lib/", + rename: "fides-stub.js", + }, + ], + verbose: true, + hook: "buildStart", + }), + ], + output: [ + { + file: "dist/fides-stub.js", + format: "es", + }, + ], +}; + +rollupOptions.push(copyStub); + /** * In addition to our regular built outputs (like fides.js!) also generate a * fides-types.d.ts file from our documented types for external use. diff --git a/clients/package-lock.json b/clients/package-lock.json index 5b311e9f1c9..452b044779b 100644 --- a/clients/package-lock.json +++ b/clients/package-lock.json @@ -257,6 +257,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "@iabtechlabtcf/stub": "1.5.21", "@rollup/plugin-alias": "^5.0.0", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.0.0", @@ -410,6 +411,7 @@ "integrity": "sha512-/z585740YHURLl9DN2jCWe6OW7zKYm6VoQ93H0sxZ1cwHQEQrUn5BJrEnkWhfzUdyO+BLGjnKUZ9iz9hKloFDw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@gerrit0/mini-shiki": "^1.24.0", "lunr": "^2.3.9", @@ -446,6 +448,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1210,6 +1213,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1441,6 +1445,7 @@ "integrity": "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -1579,6 +1584,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2397,6 +2403,7 @@ "version": "2.10.6", "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.10.6.tgz", "integrity": "sha512-9cdzcUR3LV3E2as0QhZhHAH5qjbyspV12kU1E1Ibcv6/uKUi6bIfPfMSC6R/Tw8Beqhn2ClJFPqjtXzL+C0knQ==", + "peer": true, "dependencies": { "@chakra-ui/hooks": "2.4.4", "@chakra-ui/styled-system": "2.12.2", @@ -2421,6 +2428,7 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.12.2.tgz", "integrity": "sha512-BlQ7i3+GYC0S0c72B+paa0sYo+QeNSMfz6fwQRFsc8A5Aax9i9lSdRL+vwJVC+k6r/0HWfRwk016R2RD2ihEwQ==", + "peer": true, "dependencies": { "@chakra-ui/utils": "2.2.4", "csstype": "^3.1.2" @@ -2634,6 +2642,7 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2684,6 +2693,7 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2751,7 +2761,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -2768,7 +2777,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2785,7 +2793,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2802,7 +2809,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2819,7 +2825,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2836,7 +2841,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2853,7 +2857,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2870,7 +2873,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2887,7 +2889,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2904,7 +2905,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2921,7 +2921,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2938,7 +2937,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2955,7 +2953,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2972,7 +2969,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2989,7 +2985,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3006,7 +3001,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3023,7 +3017,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3040,7 +3033,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3057,7 +3049,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3074,7 +3065,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3091,7 +3081,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3125,7 +3114,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -3142,7 +3130,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3159,7 +3146,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3176,7 +3162,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3345,6 +3330,14 @@ "version": "1.5.20", "resolved": "https://registry.npmjs.org/@iabtechlabtcf/core/-/core-1.5.20.tgz", "integrity": "sha512-Z4TH/79/zWPsv8D2rDNbqMDks08f6rmb6XVHMZkYWT9DZlS7XJ+bnNkTiTaDURo/5QNta0/8SkMe8aTiezhjYg==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@iabtechlabtcf/stub": { + "version": "1.5.21", + "resolved": "https://registry.npmjs.org/@iabtechlabtcf/stub/-/stub-1.5.21.tgz", + "integrity": "sha512-UGUErceIJExaHAi+hFJxvwt/I85q2L8ZfRvGG09IpCoYYeZmMJd81/EMPQCdyiGPlbAPTLzgauggoACyUKUC+g==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@ibm/telemetry-js": { @@ -4403,6 +4396,7 @@ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dev": true, + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", @@ -4429,6 +4423,7 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, + "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -5660,6 +5655,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -5684,6 +5680,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5697,6 +5694,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5710,6 +5708,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5723,6 +5722,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5736,6 +5736,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5749,6 +5750,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5762,6 +5764,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5775,6 +5778,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5788,6 +5792,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5801,6 +5806,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5814,6 +5820,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5827,6 +5834,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5840,6 +5848,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5853,6 +5862,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5866,6 +5876,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5879,6 +5890,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5892,6 +5904,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5905,6 +5918,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5918,6 +5932,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5931,6 +5946,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5944,6 +5960,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5957,6 +5974,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6783,6 +6801,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -7212,6 +7231,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "peer": true, "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -7422,6 +7442,7 @@ "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -7577,6 +7598,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", @@ -7741,6 +7763,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -8178,6 +8201,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8739,6 +8763,7 @@ "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", "license": "MIT", + "peer": true, "bin": { "astring": "bin/astring" } @@ -9049,7 +9074,6 @@ "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "open": "^8.0.4" }, @@ -9202,6 +9226,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -10895,6 +10920,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -11072,6 +11098,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -11942,7 +11969,6 @@ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.3.4" }, @@ -12007,6 +12033,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12441,6 +12468,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -12558,6 +12586,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, + "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -12635,6 +12664,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -12667,6 +12697,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -13098,7 +13129,8 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "peer": true }, "node_modules/esutils": { "version": "2.0.3", @@ -13721,6 +13753,7 @@ "version": "11.18.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "peer": true, "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", @@ -13810,6 +13843,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -14683,6 +14717,7 @@ "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -14691,7 +14726,8 @@ "node_modules/immutable": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", - "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==" + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", + "peer": true }, "node_modules/import-cwd": { "version": "3.0.0", @@ -15738,6 +15774,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -18741,6 +18778,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "14.2.35", "@swc/helpers": "0.5.5", @@ -20120,6 +20158,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -20770,6 +20809,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -21084,6 +21124,7 @@ "version": "0.30.1", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -21729,6 +21770,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -21860,6 +21902,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -21967,7 +22010,8 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "peer": true }, "node_modules/react-phone-number-input": { "version": "3.4.12", @@ -22334,7 +22378,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/redux-persist": { "version": "6.0.0", @@ -22636,6 +22681,7 @@ "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -23835,7 +23881,6 @@ "integrity": "sha512-es7uDdEwRVVUAt7XLAZZ1hicOq9r4ov5NFeFPpa2YEyAsyHYOCr0CTlHBfslWG6D5EVNWK3kVIIuW8GHB6hEig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -23872,7 +23917,6 @@ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -23900,7 +23944,6 @@ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -24548,6 +24591,7 @@ "version": "3.8.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -24627,6 +24671,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -24690,6 +24735,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -25578,6 +25624,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -26941,6 +26988,7 @@ "version": "19.0.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -27194,6 +27242,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.2.tgz", "integrity": "sha512-xgvB+eJxFIW0HcFmURjOOLhGmbhYVHATKeS+aQd7HeRJI3nYuutqG4eJfBi+I2rFob7E72M3tXJGjv+5XUHLEQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -27203,6 +27252,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.2.tgz", "integrity": "sha512-6N4UcB4mLYRdX3W+z9fIz+2yijW3r7laRQCV+zg8PFCZsy2iVaEQgWy3eLH0NBeQmVyi1PeLzFqXfucqeYghjw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, diff --git a/clients/privacy-center/next.config.js b/clients/privacy-center/next.config.js index f1423210fc4..20be12a55f6 100644 --- a/clients/privacy-center/next.config.js +++ b/clients/privacy-center/next.config.js @@ -29,6 +29,12 @@ const nextConfig = { source: "/:path(fides-ext-gpp.js)", destination: "/api/fides-ext-gpp-js", }, + { + // Rewrite requests for "/fides-stub.js" to the static IAB TCF CMP stub + // in public/lib, so publishers can load it before fides.js + source: "/:path(fides-stub.js)", + destination: "/lib/fides-stub.js", + }, ]; }, }; diff --git a/clients/privacy-center/public/fides-js-demo.html b/clients/privacy-center/public/fides-js-demo.html index 13bc567778c..e8f7d86fde9 100644 --- a/clients/privacy-center/public/fides-js-demo.html +++ b/clients/privacy-center/public/fides-js-demo.html @@ -4,6 +4,7 @@ fides-js script demo page +