- Implement ProfileSetup component for user profile creation with username validation. - Create ProtectedRoute component to guard routes based on authentication status. - Add VerifyAuthCode component for email verification with resend functionality. - Establish AuthContext for managing user authentication state and actions. - Enhance CookieContext for consent management with server-side verification. - Set up Firebase configuration for authentication and analytics. - Update styles for authentication components and header layout. - Configure Vite proxy for API requests during development.
180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
|
|
export interface CookieConsent {
|
|
essential: boolean; // Always true
|
|
preferences: boolean; // Theme, UI preferences
|
|
analytics: boolean; // Measurement / analytics
|
|
}
|
|
|
|
interface CookieContextType {
|
|
consent: CookieConsent;
|
|
updateConsent: (newConsent: CookieConsent) => void;
|
|
acceptAll: () => void;
|
|
rejectAll: () => void;
|
|
hasInteracted: boolean; // True if user has made a choice
|
|
showBanner: boolean;
|
|
closeBanner: () => void;
|
|
}
|
|
|
|
const CONSENT_COOKIE_NAME = 'xeewy_cookie_consent';
|
|
const CONSENT_COOKIE_MAX_AGE_DAYS = 180;
|
|
|
|
const defaultConsent: CookieConsent = {
|
|
essential: true,
|
|
preferences: false,
|
|
analytics: false,
|
|
};
|
|
|
|
const getCookie = (name: string): string | null => {
|
|
const prefix = `${name}=`;
|
|
const parts = document.cookie.split(';').map((part) => part.trim());
|
|
const found = parts.find((part) => part.startsWith(prefix));
|
|
if (!found) return null;
|
|
return found.slice(prefix.length);
|
|
};
|
|
|
|
const setCookie = (name: string, value: string, days: number) => {
|
|
const maxAge = days * 24 * 60 * 60;
|
|
const securePart = window.location.protocol === 'https:' ? '; Secure' : '';
|
|
document.cookie = `${name}=${value}; Max-Age=${maxAge}; Path=/; SameSite=Lax${securePart}`;
|
|
};
|
|
|
|
const removeCookie = (name: string) => {
|
|
const securePart = window.location.protocol === 'https:' ? '; Secure' : '';
|
|
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax${securePart}`;
|
|
};
|
|
|
|
const CookieContext = createContext<CookieContextType | undefined>(undefined);
|
|
|
|
export const CookieProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
const [consent, setConsent] = useState<CookieConsent>(defaultConsent);
|
|
const [hasInteracted, setHasInteracted] = useState(false);
|
|
const [showBanner, setShowBanner] = useState(false);
|
|
|
|
const signConsent = async (rawConsent: CookieConsent) => {
|
|
const response = await fetch('/api/cookies/sign-consent', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
consent: {
|
|
essential: true,
|
|
preferences: Boolean(rawConsent.preferences),
|
|
analytics: Boolean(rawConsent.analytics)
|
|
}
|
|
})
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as { token?: string; message?: string } | null;
|
|
if (!response.ok || !payload?.token) {
|
|
throw new Error(payload?.message || 'Unable to sign consent.');
|
|
}
|
|
return payload.token;
|
|
};
|
|
|
|
const verifyConsentToken = async (token: string) => {
|
|
const response = await fetch('/api/cookies/verify-consent', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token })
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as {
|
|
consent?: Partial<CookieConsent>;
|
|
message?: string;
|
|
} | null;
|
|
|
|
if (!response.ok || !payload?.consent) {
|
|
return null;
|
|
}
|
|
return {
|
|
...defaultConsent,
|
|
...payload.consent,
|
|
essential: true
|
|
} as CookieConsent;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const savedConsent = getCookie(CONSENT_COOKIE_NAME);
|
|
const bootstrapConsent = async () => {
|
|
if (!savedConsent) {
|
|
setShowBanner(true);
|
|
return;
|
|
}
|
|
|
|
const verified = await verifyConsentToken(savedConsent);
|
|
if (!verified) {
|
|
removeCookie(CONSENT_COOKIE_NAME);
|
|
setConsent(defaultConsent);
|
|
setHasInteracted(false);
|
|
setShowBanner(true);
|
|
return;
|
|
}
|
|
|
|
setConsent(verified);
|
|
setHasInteracted(true);
|
|
setShowBanner(false);
|
|
};
|
|
|
|
void bootstrapConsent();
|
|
}, []);
|
|
|
|
const saveConsent = async (newConsent: CookieConsent) => {
|
|
try {
|
|
const token = await signConsent(newConsent);
|
|
setCookie(CONSENT_COOKIE_NAME, token, CONSENT_COOKIE_MAX_AGE_DAYS);
|
|
setConsent(newConsent);
|
|
setHasInteracted(true);
|
|
setShowBanner(false);
|
|
} catch {
|
|
// Keep banner visible if signing fails
|
|
setShowBanner(true);
|
|
}
|
|
};
|
|
|
|
const updateConsent = (newConsent: CookieConsent) => {
|
|
void saveConsent({ ...newConsent, essential: true });
|
|
};
|
|
|
|
const acceptAll = () => {
|
|
void saveConsent({
|
|
essential: true,
|
|
preferences: true,
|
|
analytics: true,
|
|
});
|
|
};
|
|
|
|
const rejectAll = () => {
|
|
void saveConsent({
|
|
essential: true,
|
|
preferences: false,
|
|
analytics: false,
|
|
});
|
|
};
|
|
|
|
const closeBanner = () => {
|
|
setShowBanner(false);
|
|
};
|
|
|
|
return (
|
|
<CookieContext.Provider value={{
|
|
consent,
|
|
updateConsent,
|
|
acceptAll,
|
|
rejectAll,
|
|
hasInteracted,
|
|
showBanner,
|
|
closeBanner
|
|
}}>
|
|
{children}
|
|
</CookieContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useCookie = () => {
|
|
const context = useContext(CookieContext);
|
|
if (!context) {
|
|
throw new Error('useCookie must be used within a CookieProvider');
|
|
}
|
|
return context;
|
|
};
|