GDPR-Transparency-and-Consent-Framework icon indicating copy to clipboard operation
GDPR-Transparency-and-Consent-Framework copied to clipboard

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

Open rforster-dev opened this issue 10 months ago • 6 comments

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

  • If a user manually triggers the CMP via a link in the footer for example
  • and they make a change that changes their tcString
  • then return a response/event that acknowledges that.

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

I've done a work around which:

  • checks the current tcString on cmpuishown
  • on useractioncomplete if the tcString is now different, then do something (in our case, reload the page).

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

rforster-dev avatar Oct 06 '23 08:10 rforster-dev

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.

thereis avatar Oct 20 '23 13:10 thereis

@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.

HeinzBaumann avatar Nov 02 '23 00:11 HeinzBaumann

@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:

  • they have rejected all
  • they have not consented yet

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!

rforster-dev avatar Nov 07 '23 16:11 rforster-dev

@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!

HeinzBaumann avatar Nov 29 '23 17:11 HeinzBaumann

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:

  • Has the user consented?

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

  • if the CMP is being surfaced then they have not consented
  • if they have consented then they have purpose consents stored in tcfapi
  • if they have rejected all, the {} in the purpose consents stored in tcfapi, is empty and so not useful to ascertain if they have either: -- not consented yet at all -- they have, but rejected all

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

rforster-dev avatar Jan 19 '24 11:01 rforster-dev

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.

HeinzBaumann avatar Jan 22 '24 19:01 HeinzBaumann