InteractiveAdvertisingBureau / GDPR-Transparency-and-Consent-Framework

Technical specifications for IAB Europe Transparency and Consent Framework that will help the digital advertising industry interpret and comply with EU rules on data protection and privacy - notably the General Data Protection Regulation (GDPR) that comes into effect on May 25, 2018.
850 stars 355 forks source link

Ability to detect when a user has changed their consent string via manually triggering CMP #348

Open rforster-dev opened 9 months ago

rforster-dev commented 9 months ago

One thing that would be useful that I haven't seen (maybe i'm not looking hard enough!) is:

A combination of cmpuishown and useractioncomplete alone doesn't seem to be enough.

I've done a work around which:

Is this something that could be worked in as a useful event listener?

thereis commented 9 months ago

I've been struggling for quite few hours and I did a React hook to handle this situation. IMHO this whole GDPR standard is very complicated with lot's of things to understand and to realize if the user has consented or not through specific topics. Here's what I've done.

I am using Inmobi Choices as CMP, they follow the TCF standard and their script injects __tcfapi through the window object. My project is using Next.JS 13 with App router.

tcfapi.ts

export type TCData = {
  tcString: string;
  tcfPolicyVersion: number;
  cmpId: number;
  cmpVersion: number;
  gdprApplies: boolean | undefined;
  eventStatus: 'tcloaded' | 'cmpuishown' | 'useractioncomplete';
  cmpStatus: string;
  listenerId: number | undefined;
  isServiceSpecific: boolean;
  useNonStandardTexts: boolean;
  publisherCC: string;
  purposeOneTreatment: boolean;
  purpose: {
    consents: {
      [key: string]: boolean;
    };
    legitimateInterests: {
      [key: string]: boolean;
    };
  };
  vendor: {
    consents: {
      [key: string]: boolean;
    };
    legitimateInterests: {
      [key: string]: boolean;
    };
  };
  specialFeatureOptins: {
    [key: string]: boolean;
  };
  publisher: {
    consents: {
      [key: string]: boolean;
    };
    legitimateInterests: {
      [key: string]: boolean;
    };
    customPurpose: {
      consents: {
        [key: string]: boolean;
      };
      legitimateInterests: {
        [key: string]: boolean;
      };
    };
    restrictions: {
      [key: string]: {
        [key: string]: 0 | 1 | 2;
      };
    };
  };
};

export type NonIABVendorsConsents = {
  gdprApplies: boolean;
  metadata: string;
  nonIabVendorConsents: Record<number, boolean>;
};

consent-parser.ts

import { NonIABVendorsConsents, TCData } from '@/types/tcfapi';

// https://vendor-list.consensu.org/v2/vendor-list.json
export class ConsentParser {
  constructor(
    private consentData: TCData,
    private nonIABVendors: NonIABVendorsConsents['nonIabVendorConsents'],
  ) {}

  private getConsent(purposeId: string): boolean {
    return this.consentData.purpose.consents[purposeId] ?? false;
  }

  private getLegitimateInterest(purposeId: string): boolean {
    return this.consentData.purpose.legitimateInterests[purposeId] ?? false;
  }

  canSaveCookies(): boolean {
    return this.getConsent('1');
  }

  canSelectBasicAds(): boolean {
    return this.getConsent('2');
  }

  canCreatePersonalisedAdsProfile(): boolean {
    return this.getConsent('3');
  }

  canSelectPersonalisedAds(): boolean {
    return this.getConsent('4');
  }

  canCreatePersonalisedContentProfile(): boolean {
    return this.getConsent('5');
  }

  canSelectPersonalisedContent(): boolean {
    return this.getConsent('6');
  }

  canMeasureAdPerformance(): boolean {
    return this.getConsent('7');
  }

  canMeasureContentPerformance(): boolean {
    return this.getConsent('8');
  }

  canApplyMarketResearch(): boolean {
    return this.getConsent('9');
  }

  canDevelopAndImproveProducts(): boolean {
    return this.getConsent('10');
  }

  hasLegitimateInterestForStorageAccess(): boolean {
    return this.getLegitimateInterest('1');
  }

  hasLegitimateInterestForBasicAdSelection(): boolean {
    return this.getLegitimateInterest('2');
  }

  hasLegitimateInterestForCreatingPersonalisedAdsProfile(): boolean {
    return this.getLegitimateInterest('3');
  }

  hasLegitimateInterestForSelectingPersonalisedAds(): boolean {
    return this.getLegitimateInterest('4');
  }

  hasLegitimateInterestForCreatingPersonalisedContentProfile(): boolean {
    return this.getLegitimateInterest('5');
  }

  hasLegitimateInterestForSelectingPersonalisedContent(): boolean {
    return this.getLegitimateInterest('6');
  }

  hasLegitimateInterestForAdMeasurement(): boolean {
    return this.getLegitimateInterest('7');
  }

  hasLegitimateInterestForContentMeasurement(): boolean {
    return this.getLegitimateInterest('8');
  }

  hasLegitimateInterestForMarketResearch(): boolean {
    return this.getLegitimateInterest('9');
  }

  hasLegitimateInterestForProductDevelopment(): boolean {
    return this.getLegitimateInterest('10');
  }

  hasOptedInForPreciseGeolocationData(): boolean {
    return this.consentData.specialFeatureOptins['1'] ?? false;
  }

  hasOptedInForActiveDeviceScanning(): boolean {
    return this.consentData.specialFeatureOptins['2'] ?? false;
  }
}

useTCFAPI.ts

'use client';

import { useCallback, useEffect, useState } from 'react';

import { useInterval } from '@mantine/hooks';

import { NonIABVendorsConsents, TCData } from './tcfapi';
import { ConsentParser } from './consent-parser';

/**
 * It will get the defaults tcData properties
 * @see https://vendor-list.consensu.org/v2/vendor-list.json
 */
const useTCFAPI = () => {
  const [isLoading, setIsLoading] = useState(true);

  const [data, setData] = useState<TCData>();
  const [consentManager, setConsentManager] = useState<ConsentParser>();

  const [tcStatus, setTcStatus] = useState<TCData['eventStatus'] | undefined>();

  const [nonIABVendors, setNonIABVendors] =
    useState<NonIABVendorsConsents['nonIabVendorConsents']>();

  const [tcfAPI, setTcfAPI] = useState<any>();

  const { start, active, stop } = useInterval(() => {
    if (
      typeof window !== 'undefined' ||
      typeof (window as any).__tcfapi === 'function'
    ) {
      setTcfAPI(() => (window as any).__tcfapi);
    }
  }, 100);

  // Check if the window object is present in document
  useEffect(() => {
    start();

    return () => {
      stop();
    };
  }, []);

  const _handleGetNonIABVendorConsents = useCallback(
    (nonIabConsent: NonIABVendorsConsents, nonIabSuccess: boolean) => {
      nonIabSuccess && setNonIABVendors(nonIabConsent.nonIabVendorConsents);
    },
    [],
  );

  /**
   * @see https://help.quantcast.com/hc/en-us/articles/13422592233371-Choice-CMP2-CCPA-API-Index-
   */
  useEffect(() => {
    if (!tcfAPI) return;

    if (active) {
      stop();
    }

    tcfAPI('addEventListener', 2, (tcData: TCData, success: boolean) => {
      success && setData(tcData);

      tcfAPI('getNonIABVendorConsents', 2, _handleGetNonIABVendorConsents);

      setTcStatus(tcData.eventStatus);

      setIsLoading(false);
    });
  }, [tcfAPI, active]);

  /**
   * @see https://help.quantcast.com/hc/en-us/articles/13422592233371-Choice-CMP2-CCPA-API-Index-
   */
  useEffect(() => {
    if (!tcStatus) return;

    tcfAPI(
      'addEventListener',
      2,
      ({ eventStatus }: TCData, success: boolean) => {
        if (
          success &&
          (eventStatus === 'useractioncomplete' || eventStatus === 'tcloaded')
        ) {
          tcfAPI('getNonIABVendorConsents', 2, _handleGetNonIABVendorConsents);
        }
      },
    );
  }, [tcStatus]);

  useEffect(() => {
    if (!data || !nonIABVendors) return;

    setConsentManager(new ConsentParser(data, nonIABVendors));
  }, [data, nonIABVendors]);

  return { isLoading, consentManager, nonIABVendors };
};

export default useTCFAPI;

It took me the whole night to understand and to adapt this convention but I am very satisfied with the approach. I will not follow any support by my script because it has some changes to attend my needs, but that's what I've done.

HeinzBaumann commented 8 months ago

@rforster-dev Is what you are requesting something like an event called tcStringHasChanged? Currently the way to do this is how you described it. We can consider adding a new event into the eventhander if this helps further.

rforster-dev commented 8 months ago

@HeinzBaumann - Yes sort of - we've got a couple of scenarios that are similar to this.

1: Has the user actually consented yet? Currently, the tcfstring can contain: {} and empty object of consent preferences. This either means:

Which makes it difficult to understand when a user has actually consented or not. SourcePoint, a CMP handles this by using localStorage and in their kvp, they have a value of hasConsented: <boolean>

We are using this as as stopgap until we can do this natively via the tcf eventlistener.


2: Whether or not a consent string has changed As described, it's hard to know when a user has actually changed their consent status - we've mitigated this by forcing a refresh when buttons are clicked within the CMP, but this feels quite dirty. It would be nice if there was an event listener that could be ran and listened to, when a consent string has actually changed

Hope this helps!

HeinzBaumann commented 7 months ago

@rforster-dev We discussed this at the TCF Framework Signal Working Group meeting (the tech side of the TCF body). It wasn't clear from reviewing this what use case this addresses. The vendor always has to check the content of the TCString after a notification has been fired e.g. do I as vendor with id xy have consent, what are the values of the purposes flags that I need to be aware of, can I operate? The groups understanding is that this can all be done today with the existing event listener and the different APIs. We are happy to further review this once we understand your use case. Thanks!

rforster-dev commented 6 months ago

Thanks for responding - apologies I haven't said anything back since. Appreciate the groups time in reviewing this.

OK, A question that maybe i'm lacking understanding in or guidance; in the scenario of:

What is the best way to determine this, factoring in:

Does that make sense? Appreciate I might not be wording it particularly great!

HeinzBaumann commented 5 months ago

If the event listener eventStatus is equal to "useractioncomplete", and the purpose object is empty the user rejected all. If the event listener eventStatus is equal to "cmpuishown", and the purpose object is empty, the user have not taken action yet. I hope this helps.