diff --git a/src/assets/src/hooks/useGoogleAnalytics.ts b/src/assets/src/hooks/useGoogleAnalytics.ts index 88eb6d42..aaa9bb77 100644 --- a/src/assets/src/hooks/useGoogleAnalytics.ts +++ b/src/assets/src/hooks/useGoogleAnalytics.ts @@ -1,15 +1,34 @@ import { useState, useEffect } from 'react'; import GoogleAnalytics from 'react-ga4'; import { useLocation } from 'react-router-dom'; +import { useOneTrust } from './useOneTrust'; + +export enum GoogleAnalyticsConsentValue { + Denied = "denied", + Granted = "granted" +} export const useGoogleAnalytics = (googleAnalyticsId?: string, debug?: boolean) => { let location = useLocation(); + const [initializeOneTrust] = useOneTrust(); + const [initialized, setInitialized] = useState(false); const [previousPage, setPreviousPage] = useState(null as string | null); - if (googleAnalyticsId && !initialized) { - setInitialized(true); + GoogleAnalytics.gtag("consent", "default", { + ad_storage: GoogleAnalyticsConsentValue.Denied, + analytics_storage: GoogleAnalyticsConsentValue.Denied, + functionality_storage: GoogleAnalyticsConsentValue.Denied, + personalization_storage: GoogleAnalyticsConsentValue.Denied, + ad_user_data: GoogleAnalyticsConsentValue.Denied, + ad_personalization: GoogleAnalyticsConsentValue.Denied, + wait_for_update: 500 + }); GoogleAnalytics.initialize(googleAnalyticsId, { testMode: debug }); + if (initializeOneTrust) { + initializeOneTrust(GoogleAnalytics); + } + setInitialized(true); } useEffect(() => { diff --git a/src/assets/src/hooks/useOneTrust.ts b/src/assets/src/hooks/useOneTrust.ts new file mode 100644 index 00000000..4b7d34c5 --- /dev/null +++ b/src/assets/src/hooks/useOneTrust.ts @@ -0,0 +1,70 @@ +import { GA4 } from 'react-ga4/types/ga4'; +import Cookies from "js-cookie"; +import { GoogleAnalyticsConsentValue } from './useGoogleAnalytics'; + +declare global { + interface Window { + OnetrustActiveGroups?: string; + OptanonWrapper?: () => void; + } + } + +// UofM OneTrust cookie documentation: https://vpcomm.umich.edu/resources/cookie-disclosure/ +enum OneTrustCookieCategory { + StrictlyNecessary = "C0001", + Performance = "C0002", + Functionality = "C0003", + Targeting = "C0004", + SocialMedia = "C0005", +} + +export const useOneTrust = (): [(googleAnalytics:GA4) => void] | [] => + { + // Embeds the script for UofM OneTrust consent banner implementation + // See instructions at https://vpcomm.umich.edu/resources/cookie-disclosure/#3rd-party-google-analytics + const initializeOneTrust = (googleAnalytics: GA4) => { + // Callback is used by OneTrust to update Google Analytics consent tags and remove cookies + const updateGtagCallback = () => { + if (!window.OnetrustActiveGroups) { + return; + } + // Update Google Analytics consent based on OneTrust active groups + // "Strictly Necessary Cookies" are always granted. C0001 (StrictlyNecessary), C0003 (Functionality) + if (window.OnetrustActiveGroups.includes(OneTrustCookieCategory.Performance)) { + googleAnalytics.gtag("consent", "update", { analytics_storage: GoogleAnalyticsConsentValue.Granted }); + } + if (window.OnetrustActiveGroups.includes(OneTrustCookieCategory.Functionality)) { + googleAnalytics.gtag("consent", "update", { functional_storage: GoogleAnalyticsConsentValue.Granted }); + } + + // "Analytics & Advertising Cookies" are optional for EU users. C0002 (Performance) + if (window.OnetrustActiveGroups.includes(OneTrustCookieCategory.Targeting)) { + googleAnalytics.gtag("consent", "update", { + ad_storage: GoogleAnalyticsConsentValue.Granted, + ad_user_data: GoogleAnalyticsConsentValue.Granted, + ad_personalization: GoogleAnalyticsConsentValue.Granted, + personalization_storage: GoogleAnalyticsConsentValue.Granted + }); + } else { + // Remove Google Analytics cookies if tracking is declined by EU users + // Uses same library as this GA4 implementation: https://dev.to/ramonak/react-enable-google-analytics-after-a-user-grants-consent-5bg3 + Cookies.remove("_ga"); + Cookies.remove("_gat"); + Cookies.remove("_gid"); + } + googleAnalytics.event({ action: 'um_consent_updated', category: 'consent' }); + } + window.OptanonWrapper = updateGtagCallback; + + const oneTrustScriptDomain = "03e0096b-3569-4b70-8a31-918e55aa20da" + const src =`https://cdn.cookielaw.org/consent/${oneTrustScriptDomain}/otSDKStub.js` + + const script = document.createElement('script'); + script.src = src; + script.type = 'text/javascript'; + script.dataset.domainScript = oneTrustScriptDomain; + document.head.appendChild(script); + } + + return [ initializeOneTrust ]; +}; diff --git a/src/officehours/settings.py b/src/officehours/settings.py index d31565f1..d9e35691 100644 --- a/src/officehours/settings.py +++ b/src/officehours/settings.py @@ -86,6 +86,8 @@ def str_to_bool(val): LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' +PRIVACY_REDIRECT_URL = 'https://umich.edu/about/privacy/' + OIDC_RP_CLIENT_ID = os.getenv('OIDC_RP_CLIENT_ID') OIDC_RP_CLIENT_SECRET = os.getenv('OIDC_RP_CLIENT_SECRET') OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv('OIDC_OP_AUTHORIZATION_ENDPOINT') diff --git a/src/officehours_ui/urls.py b/src/officehours_ui/urls.py index fe152ce4..e2a628a3 100644 --- a/src/officehours_ui/urls.py +++ b/src/officehours_ui/urls.py @@ -2,7 +2,7 @@ from django.conf import settings from django.views.generic.base import RedirectView -from .views import SpaView, AuthPromptView, auth_callback_view +from .views import SpaView, AuthPromptView, auth_callback_view, privacy_policy_redirect_view urlpatterns = [ @@ -19,4 +19,5 @@ path('callback//', auth_callback_view, name='auth_callback'), path("robots.txt", RedirectView.as_view(url='/static/robots.txt', permanent=True)), path("favicon.ico", RedirectView.as_view(url='/static/favicon.ico', permanent=True)), + path("privacy/", privacy_policy_redirect_view, name='privacy-policy') ] diff --git a/src/officehours_ui/views.py b/src/officehours_ui/views.py index b270ce72..36164df5 100644 --- a/src/officehours_ui/views.py +++ b/src/officehours_ui/views.py @@ -1,3 +1,4 @@ +from django.shortcuts import redirect from django.views.generic import TemplateView from django.conf import settings from django.http import Http404 @@ -45,3 +46,6 @@ def auth_callback_view(request, backend_name: IMPLEMENTED_BACKEND_NAME): except AttributeError: raise Http404(f"Backend {backend_name} does not use three-legged OAuth2.") return auth_callback(request) + +def privacy_policy_redirect_view(request): + return redirect(settings.PRIVACY_REDIRECT_URL) \ No newline at end of file diff --git a/src/package-lock.json b/src/package-lock.json index 0337ae3f..f6f33365 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -11,6 +11,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/react-fontawesome": "^0.2.0", + "js-cookie": "^3.0.5", "react": "^18.2.0", "react-bootstrap": "^2.7.4", "react-dom": "^18.2.0", @@ -21,6 +22,7 @@ "yup": "^1.2.0" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/react": "^18.2.7", "@types/react-dom": "^18.2.4", "css-loader": "~6.8.1", @@ -331,6 +333,12 @@ "@types/node": "*" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -1933,6 +1941,14 @@ "node": ">=0.10.0" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/src/package.json b/src/package.json index ff4159ad..231bedc4 100644 --- a/src/package.json +++ b/src/package.json @@ -5,6 +5,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/react-fontawesome": "^0.2.0", + "js-cookie": "^3.0.5", "react": "^18.2.0", "react-bootstrap": "^2.7.4", "react-dom": "^18.2.0", @@ -21,6 +22,7 @@ "check-types": "tsc" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/react": "^18.2.7", "@types/react-dom": "^18.2.4", "css-loader": "~6.8.1",