mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAppConfig } from './useAppConfig';
|
||||
import { useAppConfig } from '../contexts/AppConfigContext'
|
||||
|
||||
export const useBaseUrl = (): string => {
|
||||
const { config } = useAppConfig();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
44
frontend/src/hooks/useScarfTracking.ts
Normal file
44
frontend/src/hooks/useScarfTracking.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user