Cookie consent banner and footer on login screen (#5042)

This commit is contained in:
Reece Browne 2025-11-28 16:24:10 +00:00 committed by GitHub
parent 250979e271
commit e4c6ce5836
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 268 additions and 66 deletions

View File

@ -159,6 +159,8 @@ public class RequestUriUtils {
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")

View File

@ -58,6 +58,21 @@ public class UIDataController {
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/footer-info")
@Operation(summary = "Get public footer configuration data")
public ResponseEntity<FooterData> getFooterData() {
FooterData data = new FooterData();
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
data.setAccessibilityStatement(
applicationProperties.getLegal().getAccessibilityStatement());
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
data.setImpressum(applicationProperties.getLegal().getImpressum());
return ResponseEntity.ok(data);
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
@ -237,6 +252,16 @@ public class UIDataController {
}
// Data classes
@Data
public static class FooterData {
private Boolean analyticsEnabled;
private String termsAndConditions;
private String privacyPolicy;
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
}
@Data
public static class HomeData {
private boolean showSurveyFromDocker;

View File

@ -24,8 +24,8 @@
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
@ -34,10 +34,10 @@
--cc-section-category-border: var(--md-sys-color-outline);
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
@ -47,7 +47,7 @@
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
--cc-footer-border-color: var(--cc-bg);
}
.cm__body{
max-width: 90% !important;
@ -81,4 +81,4 @@
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
}

View File

@ -299,7 +299,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/api/v1/invite/validate",
contextPath + "/api/v1/invite/accept"
contextPath + "/api/v1/invite/accept",
contextPath + "/api/v1/ui-data/footer-info"
};
for (String pattern : publicApiPatterns) {

View File

@ -54,17 +54,17 @@
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #2d2d2d;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #e5e5e5;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #e5e5e5;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #2d2d2d;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: #555555;
@ -180,4 +180,27 @@
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}

View File

@ -1,6 +1,7 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useFooterInfo } from '@app/hooks/useFooterInfo';
interface FooterProps {
privacyPolicy?: string;
@ -9,6 +10,7 @@ interface FooterProps {
cookiePolicy?: string;
impressum?: string;
analyticsEnabled?: boolean;
forceLightMode?: boolean;
}
export default function Footer({
@ -17,10 +19,36 @@ export default function Footer({
accessibilityStatement,
cookiePolicy,
impressum,
analyticsEnabled = false
analyticsEnabled,
forceLightMode = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
const { footerInfo } = useFooterInfo();
console.log('[Footer] Props analyticsEnabled:', analyticsEnabled);
console.log('[Footer] Fetched footerInfo:', footerInfo);
// Use props if provided, otherwise fall back to fetched footer info
const finalAnalyticsEnabled = analyticsEnabled ?? footerInfo?.analyticsEnabled ?? false;
const finalPrivacyPolicy = privacyPolicy ?? footerInfo?.privacyPolicy;
const finalTermsAndConditions = termsAndConditions ?? footerInfo?.termsAndConditions;
const finalAccessibilityStatement = accessibilityStatement ?? footerInfo?.accessibilityStatement;
const finalCookiePolicy = cookiePolicy ?? footerInfo?.cookiePolicy;
const finalImpressum = impressum ?? footerInfo?.impressum;
console.log('[Footer] Final analyticsEnabled:', finalAnalyticsEnabled);
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled: finalAnalyticsEnabled, forceLightMode });
// Default URLs
const defaultTermsUrl = "https://www.stirling.com/legal/terms-of-service";
const defaultPrivacyUrl = "https://www.stirling.com/legal/privacy-policy";
const defaultAccessibilityUrl = "https://www.stirling.com/accessibility";
// Use provided URLs or fall back to defaults
const finalTermsUrl = finalTermsAndConditions || defaultTermsUrl;
const finalPrivacyUrl = finalPrivacyPolicy || defaultPrivacyUrl;
const finalAccessibilityUrl = finalAccessibilityStatement || defaultAccessibilityUrl;
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
@ -28,8 +56,8 @@ export default function Footer({
return (
<div style={{
height: 'var(--footer-height)',
backgroundColor: 'var(--mantine-color-gray-1)',
borderTop: '1px solid var(--mantine-color-gray-2)',
backgroundColor: forceLightMode ? '#f1f3f5' : 'var(--mantine-color-gray-1)',
borderTop: forceLightMode ? '1px solid #e9ecef' : '1px solid var(--mantine-color-gray-2)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
@ -38,7 +66,10 @@ export default function Footer({
justify="center"
align="center"
direction="row"
style={{ fontSize: '0.75rem' }}>
style={{
fontSize: '0.75rem',
color: forceLightMode ? '#495057' : undefined
}}>
<a
className="footer-link px-3"
id="survey"
@ -48,57 +79,51 @@ export default function Footer({
>
{t('survey.nav', 'Survey')}
</a>
{isValidLink(privacyPolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalPrivacyUrl}
>
{t('legal.privacy', 'Privacy Policy')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalTermsUrl}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalAccessibilityUrl}
>
{t('legal.accessibility', 'Accessibility')}
</a>
{isValidLink(finalCookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={privacyPolicy}
>
{t('legal.privacy', 'Privacy Policy')}
</a>
)}
{isValidLink(termsAndConditions) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={termsAndConditions}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
)}
{isValidLink(accessibilityStatement) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={accessibilityStatement}
>
{t('legal.accessibility', 'Accessibility')}
</a>
)}
{isValidLink(cookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={cookiePolicy}
href={finalCookiePolicy}
>
{t('legal.cookie', 'Cookie Policy')}
</a>
)}
{isValidLink(impressum) && (
{isValidLink(finalImpressum) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={impressum}
href={finalImpressum}
>
{t('legal.impressum', 'Impressum')}
</a>
)}
{analyticsEnabled && (
{finalAnalyticsEnabled && (
<button
className="footer-link px-3"
id="cookieBanner"

View File

@ -19,10 +19,12 @@ declare global {
interface CookieConsentConfig {
analyticsEnabled?: boolean;
forceLightMode?: boolean;
}
export const useCookieConsent = ({
analyticsEnabled = false
analyticsEnabled = false,
forceLightMode = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
@ -69,6 +71,12 @@ export const useCookieConsent = ({
// Detect current theme and set appropriate mode
const detectTheme = () => {
// If forceLightMode is enabled, always use light mode
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
return false;
}
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
const hasLightClass = document.documentElement.classList.contains('light');
const hasDarkClass = document.documentElement.classList.contains('dark');
@ -104,21 +112,24 @@ export const useCookieConsent = ({
return;
}
// Listen for theme changes
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
// Listen for theme changes (but not if forceLightMode is enabled)
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
});
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
}
// Initialize cookie consent with full configuration
@ -245,6 +256,65 @@ export const useCookieConsent = ({
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
// Update theme when forceLightMode changes
useEffect(() => {
if (!isInitialized) return;
const detectTheme = () => {
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
return false;
}
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
const hasLightClass = document.documentElement.classList.contains('light');
const hasDarkClass = document.documentElement.classList.contains('dark');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let isDarkMode = false;
if (mantineScheme) {
isDarkMode = mantineScheme === 'dark';
} else if (hasLightClass) {
isDarkMode = false;
} else if (hasDarkClass) {
isDarkMode = true;
} else {
isDarkMode = systemPrefersDark;
}
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
return isDarkMode;
};
// Update theme immediately
detectTheme();
// Set up or remove theme observer based on forceLightMode
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
}
return () => {
if (themeObserver) {
themeObserver.disconnect();
}
};
}, [forceLightMode, isInitialized]);
// Hide cookie banner when tour is active
useEffect(() => {
if (!isInitialized || !window.CookieConsent) {

View File

@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
export interface FooterInfo {
analyticsEnabled?: boolean;
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
/**
* Hook to fetch public footer configuration data.
* This endpoint is always accessible without authentication.
*/
export function useFooterInfo() {
const [footerInfo, setFooterInfo] = useState<FooterInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchFooterInfo = async () => {
try {
setLoading(true);
console.log('[useFooterInfo] Fetching footer info from /api/v1/ui-data/footer-info...');
const response = await apiClient.get<FooterInfo>('/api/v1/ui-data/footer-info', {
suppressErrorToast: true,
} as any);
console.log('[useFooterInfo] Footer info received:', response.data);
setFooterInfo(response.data);
setError(null);
} catch (err) {
console.error('[useFooterInfo] Failed to fetch footer info:', err);
setError(err as Error);
// Set defaults on error
setFooterInfo({
analyticsEnabled: false,
});
} finally {
setLoading(false);
}
};
fetchFooterInfo();
}, []);
return { footerInfo, loading, error };
}

View File

@ -7,6 +7,7 @@ import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
import { OnboardingProvider } from '@app/contexts/OnboardingContext';
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
@ -65,7 +66,9 @@ vi.mock('react-router-dom', async () => {
// Test wrapper with MantineProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>
<PreferencesProvider>{children}</PreferencesProvider>
<PreferencesProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</PreferencesProvider>
</MantineProvider>
);

View File

@ -4,6 +4,7 @@ import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
import buildLoginSlides from '@app/components/shared/loginSlides';
import styles from '@app/routes/authShared/AuthLayout.module.css';
import { useLogoVariant } from '@app/hooks/useLogoVariant';
import Footer from '@app/components/shared/Footer';
interface AuthLayoutProps {
children: React.ReactNode
@ -67,6 +68,9 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
)}
</div>
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, width: '100%', zIndex: 10 }}>
<Footer forceLightMode={true} />
</div>
</div>
);
}