Customised Analytics for admins and users (#4687)

Adds granular privacy controls for analytics - splits single
enableAnalytics toggle into separate PostHog and Scarf controls with
improved admin
  UX.

  Backend Changes

  Configuration (ApplicationProperties.java)
  - Added enablePosthog and enableScarf boolean fields
- New methods: isPosthogEnabled(), isScarfEnabled() (null = enabled when
analytics is on)

  Services
- PostHogService: Now checks isPosthogEnabled() instead of
isAnalyticsEnabled()
  - ConfigController: Exposes new flags via API
- SettingsController: Changed endpoint from @RequestBody to
@RequestParam

  Frontend Changes

  Architecture
- Converted useAppConfig hook → AppConfigContext provider for global
access
  - Added refetch() method for config updates without reload

  New Features
1. AdminAnalyticsChoiceModal: First-launch modal when enableAnalytics
=== null
    - Enable/disable without editing YAML
    - Includes documentation link
  2. Scarf Tracking System: Modular utility with React hook wrapper
    - Respects config + per-service cookie consent
    - Works from any code location (React or vanilla JS)
3. Enhanced Cookie Consent: Per-service toggles (PostHog and Scarf
separate)

  Integration
  - App.tsx: Added AppConfigProvider + scarf initializer
  - HomePage.tsx: Shows admin modal when needed
  - index.tsx: PostHog opt-out by default, service-level consent

  Key Benefits

 Backward compatible (null defaults to enabled)
 Granular control per analytics service
 First-launch admin modal (no YAML editing)
 Privacy-focused with opt-out defaults
 API-based config updates

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh
2025-10-27 16:54:59 +00:00
committed by GitHub
parent 2e932f30bf
commit 960d48f80c
22 changed files with 418 additions and 109 deletions

View File

@@ -1,85 +0,0 @@
import { useState, useEffect } from 'react';
// Helper to get JWT from localStorage for Authorization header
function getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('stirling_jwt');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
export interface AppConfig {
baseUrl?: string;
contextPath?: string;
serverPort?: number;
appName?: string;
appNameNavbar?: string;
homeDescription?: string;
languages?: string[];
enableLogin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
privacyPolicy?: string;
cookiePolicy?: string;
impressum?: string;
accessibilityStatement?: string;
runningProOrHigher?: boolean;
runningEE?: boolean;
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
error?: string;
}
interface UseAppConfigReturn {
config: AppConfig | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
/**
* Custom hook to fetch and manage application configuration
*/
export function useAppConfig(): UseAppConfigReturn {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchConfig = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/config/app-config', {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
}
const data: AppConfig = await response.json();
setConfig(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('Failed to fetch app config:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchConfig();
}, []);
return {
config,
loading,
error,
refetch: fetchConfig,
};
}

View File

@@ -1,4 +1,4 @@
import { useAppConfig } from './useAppConfig';
import { useAppConfig } from '../contexts/AppConfigContext'
export const useBaseUrl = (): string => {
const { config } = useAppConfig();

View File

@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '../constants/app';
import { useAppConfig } from '../contexts/AppConfigContext';
declare global {
interface Window {
CookieConsent: {
CookieConsent?: {
run: (config: any) => void;
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
};
}
}
@@ -15,8 +18,11 @@ interface CookieConsentConfig {
analyticsEnabled?: boolean;
}
export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConfig = {}) => {
export const useCookieConsent = ({
analyticsEnabled = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
@@ -30,7 +36,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 100);
return;
}
@@ -130,7 +136,24 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
necessary: {
readOnly: true
},
analytics: {}
analytics: {
services: {
...(config?.enablePosthog !== false && {
posthog: {
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
onAccept: () => console.log('PostHog service accepted'),
onReject: () => console.log('PostHog service rejected')
}
}),
...(config?.enableScarf !== false && {
scarf: {
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
onAccept: () => console.log('Scarf service accepted'),
onReject: () => console.log('Scarf service rejected')
}
})
}
}
},
language: {
default: "en",
@@ -184,7 +207,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
// Force show after initialization
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 200);
} catch (error) {
@@ -212,15 +235,23 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
document.head.removeChild(customCSS);
}
};
}, [analyticsEnabled, t]);
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent.show(true);
window.CookieConsent?.show(true);
}
};
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
return false;
}
return window.CookieConsent.acceptedService(service, category);
}, []);
return {
showCookiePreferences
showCookiePreferences,
isServiceAccepted
};
};

View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { useAppConfig } from '../contexts/AppConfigContext';
import { useCookieConsent } from './useCookieConsent';
import { setScarfConfig, firePixel } from '../utils/scarfTracking';
/**
* Hook for initializing Scarf tracking
*
* This hook should be mounted once during app initialization (e.g., in index.tsx).
* It configures the scarf tracking utility with current config and consent state,
* and sets up event listeners to auto-fire pixels when consent is granted.
*
* After initialization, firePixel() can be called from anywhere in the app,
* including non-React utility functions like urlRouting.ts.
*/
export function useScarfTracking() {
const { config } = useAppConfig();
const { isServiceAccepted } = useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Update scarf config whenever config or consent changes
useEffect(() => {
if (config && config.enableScarf !== undefined) {
setScarfConfig(config.enableScarf, isServiceAccepted);
}
}, [config?.enableScarf, isServiceAccepted]);
// Listen to cookie consent changes and auto-fire pixel when consent is granted
useEffect(() => {
const handleConsentChange = () => {
console.warn('[useScarfTracking] Consent changed, checking scarf service acceptance');
if (isServiceAccepted('scarf', 'analytics')) {
firePixel(window.location.pathname);
}
};
window.addEventListener('cc:onConsent', handleConsentChange);
window.addEventListener('cc:onChange', handleConsentChange);
return () => {
window.removeEventListener('cc:onConsent', handleConsentChange);
window.removeEventListener('cc:onChange', handleConsentChange);
};
}, [isServiceAccepted]);
}