feat: add profile setup, verification, and authentication context
- 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.
This commit is contained in:
@@ -12,9 +12,15 @@ import HomeSync from './components/HomeSync';
|
||||
import HomeSyncPolicies from './components/HomeSyncPolicies';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import RootRedirect from './components/RootRedirect';
|
||||
import Login from './components/Login';
|
||||
import VerifyAuthCode from './components/VerifyAuthCode';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import ProfileSetup from './components/ProfileSetup';
|
||||
import './styles/main.scss';
|
||||
import { CookieProvider, useCookie } from './contexts/CookieContext';
|
||||
import CookieBanner from './components/CookieBanner/CookieBanner';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { initFirebaseAnalytics } from './lib/firebase';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -23,9 +29,11 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/:lang/*" element={
|
||||
<LanguageProvider>
|
||||
<AppContent />
|
||||
</LanguageProvider>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<AppContent />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
} />
|
||||
</Routes>
|
||||
</CookieProvider>
|
||||
@@ -61,6 +69,11 @@ function AppContent() {
|
||||
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode, consent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!consent.analytics) return;
|
||||
void initFirebaseAnalytics();
|
||||
}, [consent.analytics]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDarkMode(!darkMode);
|
||||
};
|
||||
@@ -75,11 +88,14 @@ function AppContent() {
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/travelmate" element={<TravelMate />} />
|
||||
<Route path="/travelmate/policies" element={<Policies />} />
|
||||
<Route path="/travelmate/erasedata" element={<EraseData />} />
|
||||
<Route path="/travelmate/erasedata" element={<ProtectedRoute><EraseData /></ProtectedRoute>} />
|
||||
<Route path="/homesync" element={<HomeSync />} />
|
||||
<Route path="/homesync/policies" element={<HomeSyncPolicies />} />
|
||||
<Route path="/policies" element={<Policies />} />
|
||||
<Route path="/travelmate/support" element={<Support />} />
|
||||
<Route path="/travelmate/support" element={<ProtectedRoute><Support /></ProtectedRoute>} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/verify-auth" element={<VerifyAuthCode />} />
|
||||
<Route path="/profile-setup" element={<ProfileSetup />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCookie } from '../../contexts/CookieContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import './cookie-banner.scss';
|
||||
@@ -6,54 +6,68 @@ import { X } from 'lucide-react';
|
||||
|
||||
const CookieBanner = () => {
|
||||
const { showBanner, acceptAll, rejectAll, updateConsent, consent } = useCookie();
|
||||
const { language } = useLanguage();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [tempConsent, setTempConsent] = useState(consent);
|
||||
|
||||
if (!showBanner) return null;
|
||||
useEffect(() => {
|
||||
setTempConsent(consent);
|
||||
}, [consent, showBanner]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBanner) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [showBanner]);
|
||||
|
||||
// Simple translations for the banner since they are specific to this component
|
||||
// In a larger app, these would go into the main translation file.
|
||||
const content = {
|
||||
fr: {
|
||||
title: 'Gestion des cookies 🍪',
|
||||
text: 'Nous utilisons des cookies pour améliorer votre expérience et enregistrer vos préférences.',
|
||||
text: 'Nous utilisons des cookies pour sécuriser la connexion et améliorer le site.',
|
||||
accept: 'Tout accepter',
|
||||
reject: 'Refuser',
|
||||
reject: 'Tout refuser',
|
||||
settings: 'Personnaliser',
|
||||
save: 'Enregistrer',
|
||||
essential: 'Essentiels',
|
||||
essentialDesc: 'Requis pour le fonctionnement (ex: langue)',
|
||||
essential: 'Essentiels & Sécurité',
|
||||
essentialDesc: 'Toujours actifs: session de connexion, protection anti-abus, langue de base.',
|
||||
preferences: 'Préférences',
|
||||
preferencesDesc: 'Thème (Dark/Light)',
|
||||
preferencesDesc: 'Thème et préférences d’interface.',
|
||||
analytics: 'Analytiques',
|
||||
analyticsDesc: 'Statistiques anonymes',
|
||||
analyticsDesc: 'Mesure d’audience (Firebase Analytics).',
|
||||
selectAll: 'Tout cocher',
|
||||
details: 'Détails des données',
|
||||
detailsLine1: 'Essentiels: état de connexion et vérification de session.',
|
||||
detailsLine2: 'Préférences: thème clair/sombre.',
|
||||
detailsLine3: 'Analytiques: statistiques de fréquentation.',
|
||||
},
|
||||
en: {
|
||||
title: 'Cookie Management 🍪',
|
||||
text: 'We use cookies to improve your experience and save your preferences.',
|
||||
text: 'We use cookies to secure sign-in and improve the site.',
|
||||
accept: 'Accept All',
|
||||
reject: 'Reject',
|
||||
reject: 'Reject All',
|
||||
settings: 'Customize',
|
||||
save: 'Save Preferences',
|
||||
essential: 'Essential',
|
||||
essentialDesc: 'Required for core functionality (e.g. language)',
|
||||
essential: 'Essential & Security',
|
||||
essentialDesc: 'Always active: sign-in session, anti-abuse protections, base language.',
|
||||
preferences: 'Preferences',
|
||||
preferencesDesc: 'Theme (Dark/Light)',
|
||||
preferencesDesc: 'Theme and UI preferences.',
|
||||
analytics: 'Analytics',
|
||||
analyticsDesc: 'Anonymous statistics',
|
||||
analyticsDesc: 'Traffic measurement (Firebase Analytics).',
|
||||
selectAll: 'Select All',
|
||||
details: 'Data details',
|
||||
detailsLine1: 'Essential: sign-in state and session verification.',
|
||||
detailsLine2: 'Preferences: light/dark theme.',
|
||||
detailsLine3: 'Analytics: audience statistics.',
|
||||
}
|
||||
};
|
||||
|
||||
// Detect language from context or fallback to fr
|
||||
// Note: We might be inside the LanguageProvider, so we can use `useLanguage`?
|
||||
// Yes, App.tsx structure shows LanguageProvider wraps AppContent.
|
||||
// But wait, the banner might need to be shown BEFORE language is fully settled?
|
||||
// Actually, LanguageContext logic defaults to 'fr'. So we are safe.
|
||||
const { language } = useLanguage();
|
||||
const txt = content[language] || content.fr;
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
updateConsent(tempConsent);
|
||||
};
|
||||
@@ -71,85 +85,101 @@ const CookieBanner = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cookie-banner">
|
||||
{!showSettings ? (
|
||||
<div className="cookie-banner__content">
|
||||
<div className="cookie-banner__text">
|
||||
<h3>{txt.title}</h3>
|
||||
<p>{txt.text}</p>
|
||||
</div>
|
||||
<div className="cookie-banner__actions">
|
||||
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={() => setShowSettings(true)}>
|
||||
{txt.settings}
|
||||
</button>
|
||||
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={rejectAll}>
|
||||
{txt.reject}
|
||||
</button>
|
||||
<button className="cookie-banner__button cookie-banner__button--primary" onClick={acceptAll}>
|
||||
{txt.accept}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cookie-banner__settings-view">
|
||||
<div className="cookie-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="cookie-banner">
|
||||
{!showSettings ? (
|
||||
<div className="cookie-banner__content">
|
||||
<div className="cookie-banner__text">
|
||||
<h3>{txt.settings}</h3>
|
||||
<h3>{txt.title}</h3>
|
||||
<p>{txt.text}</p>
|
||||
<div className="cookie-banner__details">
|
||||
<strong>{txt.details}</strong>
|
||||
<ul>
|
||||
<li>{txt.detailsLine1}</li>
|
||||
<li>{txt.detailsLine2}</li>
|
||||
<li>{txt.detailsLine3}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cookie-banner__actions">
|
||||
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={() => setShowSettings(true)}>
|
||||
{txt.settings}
|
||||
</button>
|
||||
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={rejectAll}>
|
||||
{txt.reject}
|
||||
</button>
|
||||
<button className="cookie-banner__button cookie-banner__button--primary" onClick={acceptAll}>
|
||||
{txt.accept}
|
||||
</button>
|
||||
</div>
|
||||
<button className="cookie-banner__button--text" onClick={() => setShowSettings(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__settings">
|
||||
<div className="cookie-banner__header-actions" style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="cookie-banner__button--link" onClick={handleSelectAll} style={{ background: 'none', border: 'none', color: '#007bff', cursor: 'pointer', textDecoration: 'underline', fontSize: '0.9rem' }}>
|
||||
{txt.selectAll}
|
||||
) : (
|
||||
<div className="cookie-banner__settings-view">
|
||||
<div className="cookie-banner__content">
|
||||
<div className="cookie-banner__text">
|
||||
<h3>{txt.settings}</h3>
|
||||
</div>
|
||||
<button className="cookie-banner__button--text" onClick={() => setShowSettings(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__option">
|
||||
<label>
|
||||
<strong>{txt.essential}</strong>
|
||||
<small>{txt.essentialDesc}</small>
|
||||
</label>
|
||||
<input type="checkbox" checked disabled />
|
||||
<div className="cookie-banner__settings">
|
||||
<div className="cookie-banner__header-actions">
|
||||
<button className="cookie-banner__button--link" onClick={handleSelectAll}>
|
||||
{txt.selectAll}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__option">
|
||||
<label>
|
||||
<strong>{txt.essential}</strong>
|
||||
<small>{txt.essentialDesc}</small>
|
||||
</label>
|
||||
<input type="checkbox" checked disabled />
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__option">
|
||||
<label htmlFor="pref-check">
|
||||
<strong>{txt.preferences}</strong>
|
||||
<small>{txt.preferencesDesc}</small>
|
||||
</label>
|
||||
<input
|
||||
id="pref-check"
|
||||
type="checkbox"
|
||||
checked={tempConsent.preferences}
|
||||
onChange={() => togglePreference('preferences')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__option">
|
||||
<label htmlFor="analytics-check">
|
||||
<strong>{txt.analytics}</strong>
|
||||
<small>{txt.analyticsDesc}</small>
|
||||
</label>
|
||||
<input
|
||||
id="analytics-check"
|
||||
type="checkbox"
|
||||
checked={tempConsent.analytics}
|
||||
onChange={() => togglePreference('analytics')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__option">
|
||||
<label htmlFor="pref-check">
|
||||
<strong>{txt.preferences}</strong>
|
||||
<small>{txt.preferencesDesc}</small>
|
||||
</label>
|
||||
<input
|
||||
id="pref-check"
|
||||
type="checkbox"
|
||||
checked={tempConsent.preferences}
|
||||
onChange={() => togglePreference('preferences')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__option">
|
||||
<label htmlFor="analytics-check">
|
||||
<strong>{txt.analytics}</strong>
|
||||
<small>{txt.analyticsDesc}</small>
|
||||
</label>
|
||||
<input
|
||||
id="analytics-check"
|
||||
type="checkbox"
|
||||
checked={tempConsent.analytics}
|
||||
onChange={() => togglePreference('analytics')}
|
||||
/>
|
||||
<div className="cookie-banner__actions cookie-banner__actions--settings">
|
||||
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={rejectAll}>
|
||||
{txt.reject}
|
||||
</button>
|
||||
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={acceptAll}>
|
||||
{txt.accept}
|
||||
</button>
|
||||
<button className="cookie-banner__button cookie-banner__button--primary" onClick={handleSaveSettings}>
|
||||
{txt.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cookie-banner__actions" style={{ marginTop: '1rem' }}>
|
||||
<button className="cookie-banner__button cookie-banner__button--primary" onClick={handleSaveSettings}>
|
||||
{txt.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,156 +2,216 @@
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.cookie-banner {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
background: var(--surface-light);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
animation: slideUp 0.5s ease-out;
|
||||
position: relative;
|
||||
width: min(860px, 94vw);
|
||||
max-height: min(86vh, 860px);
|
||||
overflow: auto;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(19, 35, 65, 0.12);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 20px 48px rgba(12, 22, 46, 0.2);
|
||||
z-index: 1001;
|
||||
animation: slideUp 0.24s ease-out;
|
||||
|
||||
/* Theme variables handling */
|
||||
:global([data-theme='dark']) & {
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #f1f1f1;
|
||||
[data-theme='dark'] & {
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border-color: rgba(152, 180, 237, 0.22);
|
||||
color: #e8efff;
|
||||
box-shadow: 0 20px 48px rgba(2, 6, 23, 0.45);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
opacity: 0.95;
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__details {
|
||||
margin-top: 0.6rem;
|
||||
background: rgba(78, 129, 224, 0.08);
|
||||
border: 1px solid rgba(78, 129, 224, 0.15);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
|
||||
[data-theme='dark'] & {
|
||||
background: rgba(83, 140, 245, 0.12);
|
||||
border-color: rgba(110, 166, 255, 0.22);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.3rem 0 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
&__actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.62rem 0.8rem;
|
||||
border-radius: 0.62rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
min-height: 40px;
|
||||
|
||||
&--primary {
|
||||
background: var(--primary);
|
||||
background: linear-gradient(135deg, #2a6de0, #2390c2);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
color: inherit;
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
background: rgba(120, 136, 164, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
padding: 0.6rem 0.5rem;
|
||||
text-decoration: none;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&--link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Settings Modal/Panel styles could go here if managed within the same CSS,
|
||||
but keeping it simple for now */
|
||||
&__settings {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
margin-top: 0.8rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
gap: 0.6rem;
|
||||
|
||||
:global([data-theme='dark']) & {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
[data-theme='dark'] & {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: 0.46rem 0.1rem;
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #2a6de0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&__header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
&__actions--settings {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.cookie-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(8, 14, 27, 0.56);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translate(-50%, 100%);
|
||||
transform: translateY(14px) scale(0.985);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, 0);
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Menu, X, Sun, Moon, Globe } from 'lucide-react';
|
||||
import { Menu, X, Sun, Moon, Globe, LogIn, LogOut } from 'lucide-react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface HeaderProps {
|
||||
darkMode: boolean;
|
||||
@@ -12,24 +13,36 @@ interface HeaderProps {
|
||||
const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const { user, loading, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'home', name: t('nav.home'), href: '#hero' },
|
||||
{ id: 'about', name: t('nav.about'), href: '#about' },
|
||||
{ id: 'skills', name: t('nav.skills'), href: '#skills' },
|
||||
{ id: 'projects', name: t('nav.projects'), href: '#projects' },
|
||||
{ id: 'education', name: t('nav.education'), href: '#education' },
|
||||
{ id: 'contact', name: t('nav.contact'), href: '#contact' }
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
kind: 'section' | 'route';
|
||||
};
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ id: 'home', name: t('nav.home'), href: '#hero', kind: 'section' },
|
||||
{ id: 'about', name: t('nav.about'), href: '#about', kind: 'section' },
|
||||
{ id: 'skills', name: t('nav.skills'), href: '#skills', kind: 'section' },
|
||||
{ id: 'projects', name: t('nav.projects'), href: '#projects', kind: 'section' },
|
||||
{ id: 'education', name: t('nav.education'), href: '#education', kind: 'section' },
|
||||
{ id: 'contact', name: t('nav.contact'), href: '#contact', kind: 'section' }
|
||||
];
|
||||
|
||||
const handleNavigation = (href: string) => {
|
||||
const handleNavigation = (item: MenuItem) => {
|
||||
setIsMenuOpen(false);
|
||||
if (item.kind === 'route') {
|
||||
navigate(item.href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (location.pathname === `/${language}` || location.pathname === `/${language}/`) {
|
||||
// Already on home, just scroll
|
||||
const element = document.querySelector(href);
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
@@ -38,7 +51,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
navigate(`/${language}`);
|
||||
// Small timeout to allow navigation to render Home before scrolling
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(href);
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
@@ -50,6 +63,14 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
setLanguage(language === 'fr' ? 'en' : 'fr');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const avatarLabel = user?.displayName?.trim()?.charAt(0)?.toUpperCase() || user?.email?.trim()?.charAt(0)?.toUpperCase() || 'U';
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -100 }}
|
||||
@@ -65,33 +86,13 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
>
|
||||
<Link to={`/${language}`} onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNavigation('#hero');
|
||||
handleNavigation({ id: 'brand', name: 'home', href: '#hero', kind: 'section' });
|
||||
}}>
|
||||
Dayron Van Leemput
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Navigation desktop */}
|
||||
<ul className="nav-menu desktop-menu">
|
||||
{menuItems.map((item, index) => (
|
||||
<motion.li
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNavigation(item.href);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="nav-spacer" />
|
||||
|
||||
<div className="nav-controls">
|
||||
{/* Toggle langue */}
|
||||
@@ -118,6 +119,37 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</motion.button>
|
||||
|
||||
{!loading && user ? (
|
||||
<>
|
||||
<div className="auth-avatar" title={user.email ?? ''} aria-label={user.email ?? 'User avatar'}>
|
||||
{user.photoURL ? <img src={user.photoURL} alt={user.displayName || 'Avatar utilisateur'} /> : <span>{avatarLabel}</span>}
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleLogout}
|
||||
className="auth-toggle auth-toggle--logout"
|
||||
aria-label={language === 'fr' ? 'Se déconnecter' : 'Sign out'}
|
||||
title={language === 'fr' ? 'Se déconnecter' : 'Sign out'}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span className="logout-text">{language === 'fr' ? 'Se déconnecter' : 'Sign out'}</span>
|
||||
</motion.button>
|
||||
</>
|
||||
) : (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => navigate(`/${language}/login`)}
|
||||
className="auth-toggle auth-toggle--login"
|
||||
aria-label={language === 'fr' ? 'Aller à la page de connexion' : 'Go to login page'}
|
||||
title={language === 'fr' ? 'Aller à la page de connexion' : 'Go to login page'}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
<span className="auth-toggle-text">{language === 'fr' ? 'Connexion' : 'Sign in'}</span>
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Menu hamburger mobile */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
@@ -130,30 +162,27 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Navigation mobile */}
|
||||
<motion.ul
|
||||
className={`nav-menu mobile-menu ${isMenuOpen ? 'open' : ''}`}
|
||||
initial={false}
|
||||
animate={isMenuOpen ? { opacity: 1, x: 0 } : { opacity: 0, x: '100%' }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{menuItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNavigation(item.href);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
<div className={`nav-drawer ${isMenuOpen ? 'open' : ''}`}>
|
||||
<ul className="nav-drawer-list">
|
||||
{menuItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNavigation(item);
|
||||
}}
|
||||
className={item.kind === 'route' && location.pathname === item.href ? 'is-active' : ''}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</motion.header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
|
||||
272
frontend/src/components/Login.tsx
Normal file
272
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
const PASSWORD_POLICY = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{12,}$/;
|
||||
const USERNAME_POLICY = /^(?=.{3,24}$)[a-zA-Z0-9._-]+$/;
|
||||
|
||||
const toAuthMessage = (errorCode: string, language: 'fr' | 'en') => {
|
||||
if (language === 'fr') {
|
||||
switch (errorCode) {
|
||||
case 'auth/invalid-credential':
|
||||
case 'auth/wrong-password':
|
||||
case 'auth/user-not-found':
|
||||
return 'Identifiants invalides.';
|
||||
case 'auth/email-already-in-use':
|
||||
return 'Cet email est déjà utilisé.';
|
||||
case 'auth/invalid-email':
|
||||
return 'Adresse email invalide.';
|
||||
case 'auth/too-many-requests':
|
||||
return 'Trop de tentatives. Réessaie plus tard.';
|
||||
case 'auth/unauthorized-domain':
|
||||
return 'Domaine non autorisé dans Firebase Auth.';
|
||||
case 'auth/operation-not-allowed':
|
||||
return 'Connexion Google non activée dans Firebase.';
|
||||
case 'auth/popup-blocked':
|
||||
return 'Popup bloquée. Autorise les popups ou utilise la redirection.';
|
||||
case 'auth/popup-closed-by-user':
|
||||
return 'Connexion annulée.';
|
||||
case 'auth/network-request-failed':
|
||||
return 'Problème réseau. Vérifie ta connexion.';
|
||||
default:
|
||||
return 'Une erreur est survenue.';
|
||||
}
|
||||
}
|
||||
|
||||
switch (errorCode) {
|
||||
case 'auth/invalid-credential':
|
||||
case 'auth/wrong-password':
|
||||
case 'auth/user-not-found':
|
||||
return 'Invalid credentials.';
|
||||
case 'auth/email-already-in-use':
|
||||
return 'This email is already in use.';
|
||||
case 'auth/invalid-email':
|
||||
return 'Invalid email address.';
|
||||
case 'auth/too-many-requests':
|
||||
return 'Too many attempts. Try again later.';
|
||||
case 'auth/unauthorized-domain':
|
||||
return 'Unauthorized domain in Firebase Auth.';
|
||||
case 'auth/operation-not-allowed':
|
||||
return 'Google sign-in is not enabled in Firebase.';
|
||||
case 'auth/popup-blocked':
|
||||
return 'Popup blocked. Allow popups or use redirect.';
|
||||
case 'auth/popup-closed-by-user':
|
||||
return 'Sign-in canceled.';
|
||||
case 'auth/network-request-failed':
|
||||
return 'Network issue. Check your connection.';
|
||||
default:
|
||||
return 'Something went wrong.';
|
||||
}
|
||||
};
|
||||
|
||||
const Login = () => {
|
||||
const { language } = useLanguage();
|
||||
const {
|
||||
user,
|
||||
loading,
|
||||
codeVerified,
|
||||
verificationLoading,
|
||||
loginWithEmail,
|
||||
registerWithEmail,
|
||||
loginWithGoogle,
|
||||
checkUsernameAvailability,
|
||||
registerProfile
|
||||
} = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !verificationLoading && user) {
|
||||
navigate(codeVerified ? `/${language}` : `/${language}/verify-auth`, { replace: true });
|
||||
}
|
||||
}, [codeVerified, language, loading, navigate, user, verificationLoading]);
|
||||
|
||||
const passwordValid = PASSWORD_POLICY.test(password);
|
||||
const usernameValid = USERNAME_POLICY.test(username);
|
||||
|
||||
const submitEmailAuth = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (mode === 'register' && !passwordValid) {
|
||||
setError(
|
||||
language === 'fr'
|
||||
? 'Mot de passe invalide: min 12, 1 majuscule, 1 minuscule, 1 chiffre, 1 spécial.'
|
||||
: 'Invalid password: min 12, 1 uppercase, 1 lowercase, 1 number, 1 special.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'register' && !usernameValid) {
|
||||
setError(
|
||||
language === 'fr'
|
||||
? 'Pseudo invalide: 3 à 24 caractères, lettres/chiffres/._- uniquement.'
|
||||
: 'Invalid username: 3 to 24 chars, letters/numbers/._- only.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (mode === 'login') {
|
||||
await loginWithEmail(email, password);
|
||||
} else {
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
const available = await checkUsernameAvailability(normalizedUsername);
|
||||
if (!available) {
|
||||
setError(language === 'fr' ? 'Ce pseudo est déjà utilisé.' : 'This username is already taken.');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await registerWithEmail(email, password);
|
||||
await registerProfile({
|
||||
email: credential.user.email || email,
|
||||
firebaseUid: credential.user.uid,
|
||||
username: normalizedUsername
|
||||
});
|
||||
|
||||
setSuccess(language === 'fr' ? 'Compte créé avec succès.' : 'Account created successfully.');
|
||||
}
|
||||
} catch (caught) {
|
||||
const errorCode = typeof caught === 'object' && caught !== null && 'code' in caught ? String((caught as { code?: string }).code) : '';
|
||||
if (errorCode) {
|
||||
setError(toAuthMessage(errorCode, language));
|
||||
} else {
|
||||
setError(caught instanceof Error ? caught.message : language === 'fr' ? 'Une erreur est survenue.' : 'Something went wrong.');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitGoogle = async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
await loginWithGoogle();
|
||||
} catch (caught) {
|
||||
const errorCode =
|
||||
typeof caught === 'object' && caught !== null && 'code' in caught ? String((caught as { code?: string }).code) : '';
|
||||
if (errorCode) {
|
||||
setError(toAuthMessage(errorCode, language));
|
||||
} else {
|
||||
setError(caught instanceof Error ? caught.message : toAuthMessage('', language));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>{language === 'fr' ? 'Connexion' : 'Sign in'}</h1>
|
||||
<p>{language === 'fr' ? 'Connecte-toi à ton compte' : 'Sign in to your account'}</p>
|
||||
|
||||
<div className="auth-tabs" role="tablist" aria-label="auth modes">
|
||||
<button type="button" className={mode === 'login' ? 'is-active' : ''} onClick={() => setMode('login')}>
|
||||
{language === 'fr' ? 'Se connecter' : 'Sign in'}
|
||||
</button>
|
||||
<button type="button" className={mode === 'register' ? 'is-active' : ''} onClick={() => setMode('register')}>
|
||||
{language === 'fr' ? 'Créer un compte' : 'Create account'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitEmailAuth} className="auth-form">
|
||||
{mode === 'register' ? (
|
||||
<>
|
||||
<label htmlFor="auth-username">{language === 'fr' ? 'Pseudo' : 'Username'}</label>
|
||||
<input
|
||||
id="auth-username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={24}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="auth-email">{language === 'fr' ? 'Email' : 'Email'}</label>
|
||||
<input
|
||||
id="auth-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="auth-password">{language === 'fr' ? 'Mot de passe' : 'Password'}</label>
|
||||
<input
|
||||
id="auth-password"
|
||||
type="password"
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
minLength={mode === 'register' ? 12 : 1}
|
||||
/>
|
||||
|
||||
{mode === 'register' ? (
|
||||
<ul className="auth-rules">
|
||||
<li className={usernameValid ? 'ok' : ''}>
|
||||
{language === 'fr' ? 'Pseudo unique (3-24, lettres/chiffres/._-)' : 'Unique username (3-24, letters/numbers/._-)'}
|
||||
</li>
|
||||
<li className={password.length >= 12 ? 'ok' : ''}>{language === 'fr' ? '12+ caractères' : '12+ characters'}</li>
|
||||
<li className={/[A-Z]/.test(password) ? 'ok' : ''}>{language === 'fr' ? '1 majuscule' : '1 uppercase'}</li>
|
||||
<li className={/[a-z]/.test(password) ? 'ok' : ''}>{language === 'fr' ? '1 minuscule' : '1 lowercase'}</li>
|
||||
<li className={/\d/.test(password) ? 'ok' : ''}>{language === 'fr' ? '1 chiffre' : '1 number'}</li>
|
||||
<li className={/[^A-Za-z\d]/.test(password) ? 'ok' : ''}>
|
||||
{language === 'fr' ? '1 caractère spécial' : '1 special character'}
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting
|
||||
? language === 'fr'
|
||||
? 'Veuillez patienter...'
|
||||
: 'Please wait...'
|
||||
: mode === 'login'
|
||||
? language === 'fr'
|
||||
? 'Se connecter'
|
||||
: 'Sign in'
|
||||
: language === 'fr'
|
||||
? 'Créer le compte'
|
||||
: 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button type="button" className="google-auth google-auth--provider" onClick={submitGoogle} disabled={submitting}>
|
||||
<img src="/google.png" alt="" aria-hidden="true" className="google-auth__icon" />
|
||||
{language === 'fr' ? 'Continuer avec Google' : 'Continue with Google'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="auth-message auth-message--error">{error}</p> : null}
|
||||
{success ? <p className="auth-message auth-message--success">{success}</p> : null}
|
||||
|
||||
<Link to={`/${language}`} className="auth-back-link">
|
||||
{language === 'fr' ? 'Retour à l’accueil' : 'Back to home'}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
109
frontend/src/components/ProfileSetup.tsx
Normal file
109
frontend/src/components/ProfileSetup.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const USERNAME_POLICY = /^(?=.{3,24}$)[a-zA-Z0-9._-]+$/;
|
||||
|
||||
const ProfileSetup = () => {
|
||||
const { language } = useLanguage();
|
||||
const { user, loading, codeVerified, hasProfile, profileLoading, checkUsernameAvailability, registerProfile } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
navigate(`/${language}/login`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!loading && user && !codeVerified) {
|
||||
navigate(`/${language}/verify-auth`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!profileLoading && hasProfile) {
|
||||
navigate(`/${language}`, { replace: true });
|
||||
}
|
||||
}, [codeVerified, hasProfile, language, loading, navigate, profileLoading, user]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const normalizedUsername = username.trim().toLowerCase();
|
||||
if (!USERNAME_POLICY.test(normalizedUsername)) {
|
||||
setError(
|
||||
language === 'fr'
|
||||
? 'Pseudo invalide: 3 à 24 caractères, lettres/chiffres/._- uniquement.'
|
||||
: 'Invalid username: 3 to 24 chars, letters/numbers/._- only.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user?.email) {
|
||||
setError(language === 'fr' ? 'Utilisateur non connecté.' : 'User is not signed in.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBusy(true);
|
||||
const available = await checkUsernameAvailability(normalizedUsername);
|
||||
if (!available) {
|
||||
setError(language === 'fr' ? 'Ce pseudo est déjà utilisé.' : 'This username is already taken.');
|
||||
return;
|
||||
}
|
||||
|
||||
await registerProfile({
|
||||
email: user.email,
|
||||
firebaseUid: user.uid,
|
||||
username: normalizedUsername
|
||||
});
|
||||
|
||||
setSuccess(language === 'fr' ? 'Profil enregistré.' : 'Profile saved.');
|
||||
navigate(`/${language}`, { replace: true });
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : language === 'fr' ? 'Une erreur est survenue.' : 'Something went wrong.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>{language === 'fr' ? 'Choisis ton pseudo' : 'Choose your username'}</h1>
|
||||
<p>
|
||||
{language === 'fr'
|
||||
? 'Ton pseudo doit être unique pour finaliser ton compte.'
|
||||
: 'Your username must be unique to complete your account.'}
|
||||
</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label htmlFor="profile-username">{language === 'fr' ? 'Pseudo' : 'Username'}</label>
|
||||
<input
|
||||
id="profile-username"
|
||||
type="text"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={24}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? (language === 'fr' ? 'Veuillez patienter...' : 'Please wait...') : language === 'fr' ? 'Valider' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error ? <p className="auth-message auth-message--error">{error}</p> : null}
|
||||
{success ? <p className="auth-message auth-message--success">{success}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSetup;
|
||||
35
frontend/src/components/ProtectedRoute.tsx
Normal file
35
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: ReactNode }) => {
|
||||
const { user, loading, codeVerified, verificationLoading, hasProfile, profileLoading } = useAuth();
|
||||
const params = useParams();
|
||||
const lang = params.lang === 'en' ? 'en' : 'fr';
|
||||
|
||||
if (loading || verificationLoading || profileLoading) {
|
||||
return (
|
||||
<section className="auth-page">
|
||||
<div className="auth-card">
|
||||
<p>{lang === 'fr' ? 'Chargement...' : 'Loading...'}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to={`/${lang}/login`} replace />;
|
||||
}
|
||||
|
||||
if (!codeVerified) {
|
||||
return <Navigate to={`/${lang}/verify-auth`} replace />;
|
||||
}
|
||||
|
||||
if (!hasProfile) {
|
||||
return <Navigate to={`/${lang}/profile-setup`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
130
frontend/src/components/VerifyAuthCode.tsx
Normal file
130
frontend/src/components/VerifyAuthCode.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const VerifyAuthCode = () => {
|
||||
const { language } = useLanguage();
|
||||
const { user, loading, codeVerified, sendLoginCode, verifyLoginCode, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const isGoogleOnlyUser = Boolean(
|
||||
user?.providerData.some((entry) => entry.providerId === 'google.com') &&
|
||||
!user?.providerData.some((entry) => entry.providerId === 'password')
|
||||
);
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
navigate(`/${language}/login`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!loading && codeVerified) {
|
||||
navigate(`/${language}`, { replace: true });
|
||||
}
|
||||
if (!loading && user && isGoogleOnlyUser) {
|
||||
navigate(`/${language}`, { replace: true });
|
||||
}
|
||||
}, [codeVerified, isGoogleOnlyUser, language, loading, navigate, user]);
|
||||
|
||||
useEffect(() => {
|
||||
const autoSendCode = async () => {
|
||||
if (!user || codeVerified || isGoogleOnlyUser) return;
|
||||
try {
|
||||
await sendLoginCode();
|
||||
setMessage(language === 'fr' ? 'Code envoyé par email.' : 'Code sent by email.');
|
||||
} catch (caught) {
|
||||
const fallback = language === 'fr' ? 'Impossible d’envoyer le code.' : 'Unable to send code.';
|
||||
setError(caught instanceof Error ? caught.message : fallback);
|
||||
}
|
||||
};
|
||||
|
||||
void autoSendCode();
|
||||
}, [codeVerified, isGoogleOnlyUser, language, sendLoginCode, user]);
|
||||
|
||||
const handleResend = async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
if (isGoogleOnlyUser) {
|
||||
navigate(`/${language}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
await sendLoginCode();
|
||||
setMessage(language === 'fr' ? 'Nouveau code envoyé.' : 'New code sent.');
|
||||
} catch (caught) {
|
||||
const fallback = language === 'fr' ? 'Impossible d’envoyer le code.' : 'Unable to send code.';
|
||||
setError(caught instanceof Error ? caught.message : fallback);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
await verifyLoginCode(code.trim());
|
||||
setMessage(language === 'fr' ? 'Connexion vérifiée.' : 'Login verified.');
|
||||
navigate(`/${language}`, { replace: true });
|
||||
} catch (caught) {
|
||||
const fallback = language === 'fr' ? 'Code invalide ou expiré.' : 'Invalid or expired code.';
|
||||
setError(caught instanceof Error ? caught.message : fallback);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>{language === 'fr' ? 'Vérification de connexion' : 'Login verification'}</h1>
|
||||
<p>
|
||||
{language === 'fr'
|
||||
? 'Entre le code reçu par email pour terminer la connexion.'
|
||||
: 'Enter the code sent by email to complete sign in.'}
|
||||
</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleVerify}>
|
||||
<label htmlFor="verification-code">{language === 'fr' ? 'Code à 6 chiffres' : '6-digit code'}</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<button type="submit" disabled={busy || code.length !== 6}>
|
||||
{busy ? (language === 'fr' ? 'Veuillez patienter...' : 'Please wait...') : language === 'fr' ? 'Vérifier' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button type="button" className="google-auth" onClick={handleResend} disabled={busy}>
|
||||
{language === 'fr' ? 'Renvoyer un code' : 'Resend code'}
|
||||
</button>
|
||||
|
||||
<button type="button" className="google-auth" onClick={() => void logout()} disabled={busy}>
|
||||
{language === 'fr' ? 'Se déconnecter' : 'Sign out'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="auth-message auth-message--error">{error}</p> : null}
|
||||
{message ? <p className="auth-message auth-message--success">{message}</p> : null}
|
||||
|
||||
<Link to={`/${language}`} className="auth-back-link">
|
||||
{language === 'fr' ? 'Retour à l’accueil' : 'Back to home'}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyAuthCode;
|
||||
348
frontend/src/contexts/AuthContext.tsx
Normal file
348
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
GoogleAuthProvider,
|
||||
createUserWithEmailAndPassword,
|
||||
onAuthStateChanged,
|
||||
signInWithEmailAndPassword,
|
||||
signInWithPopup,
|
||||
signInWithRedirect,
|
||||
signOut,
|
||||
type UserCredential,
|
||||
type User
|
||||
} from 'firebase/auth';
|
||||
import { auth } from '../lib/firebase';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
codeVerified: boolean;
|
||||
verificationLoading: boolean;
|
||||
hasProfile: boolean;
|
||||
profileLoading: boolean;
|
||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||
registerWithEmail: (email: string, password: string) => Promise<UserCredential>;
|
||||
checkUsernameAvailability: (username: string) => Promise<boolean>;
|
||||
registerProfile: (params: { email: string; firebaseUid: string; username: string }) => Promise<void>;
|
||||
refreshProfileStatus: () => Promise<void>;
|
||||
loginWithGoogle: () => Promise<void>;
|
||||
sendLoginCode: () => Promise<void>;
|
||||
verifyLoginCode: (code: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const provider = new GoogleAuthProvider();
|
||||
provider.setCustomParameters({ prompt: 'select_account' });
|
||||
const VERIFICATION_STORAGE_KEY = 'auth_code_session_v1';
|
||||
const ABSOLUTE_MAX_SESSION_MS = 24 * 60 * 60 * 1000;
|
||||
const PASSWORD_PROVIDER_ID = 'password';
|
||||
const GOOGLE_PROVIDER_ID = 'google.com';
|
||||
|
||||
const readStoredSession = (): { email: string; token: string; expiresAt?: number; createdAt?: number } | null => {
|
||||
const rawValue = localStorage.getItem(VERIFICATION_STORAGE_KEY);
|
||||
if (!rawValue) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as { email?: string; token?: string; expiresAt?: number; createdAt?: number };
|
||||
if (!parsed.email || !parsed.token) return null;
|
||||
return { email: parsed.email, token: parsed.token, expiresAt: parsed.expiresAt, createdAt: parsed.createdAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [codeVerified, setCodeVerified] = useState(false);
|
||||
const [verificationLoading, setVerificationLoading] = useState(true);
|
||||
const [hasProfile, setHasProfile] = useState(false);
|
||||
const [profileLoading, setProfileLoading] = useState(true);
|
||||
|
||||
const isGoogleUser = (nextUser: User | null) => nextUser?.providerData.some((entry) => entry.providerId === GOOGLE_PROVIDER_ID) ?? false;
|
||||
const usesPasswordAuth = (nextUser: User | null) =>
|
||||
nextUser?.providerData.some((entry) => entry.providerId === PASSWORD_PROVIDER_ID) ?? false;
|
||||
|
||||
const revokeServerSession = async (email: string, token: string) => {
|
||||
await fetch('/api/auth/logout-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, token })
|
||||
}).catch(() => null);
|
||||
};
|
||||
|
||||
const hardLogout = async () => {
|
||||
const storedSession = readStoredSession();
|
||||
const email = user?.email || storedSession?.email;
|
||||
if (email && storedSession?.token) {
|
||||
await revokeServerSession(email, storedSession.token);
|
||||
}
|
||||
await signOut(auth);
|
||||
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
|
||||
setCodeVerified(false);
|
||||
setVerificationLoading(false);
|
||||
setHasProfile(false);
|
||||
setProfileLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (nextUser) => {
|
||||
setUser(nextUser);
|
||||
setLoading(false);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const validateStoredSession = async () => {
|
||||
if (!user?.email) {
|
||||
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
|
||||
setCodeVerified(false);
|
||||
setVerificationLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Google auth skips the email code challenge.
|
||||
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
|
||||
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
|
||||
setCodeVerified(true);
|
||||
setVerificationLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedSession = readStoredSession();
|
||||
if (!storedSession || storedSession.email !== user.email.toLowerCase()) {
|
||||
setCodeVerified(false);
|
||||
setVerificationLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAt = Number(storedSession.expiresAt || 0);
|
||||
const createdAt = Number(storedSession.createdAt || 0);
|
||||
const now = Date.now();
|
||||
const absoluteExpiry = createdAt > 0 ? createdAt + ABSOLUTE_MAX_SESSION_MS : 0;
|
||||
const effectiveExpiry = expiresAt > 0 && absoluteExpiry > 0 ? Math.min(expiresAt, absoluteExpiry) : 0;
|
||||
|
||||
if (effectiveExpiry > 0 && now >= effectiveExpiry) {
|
||||
await hardLogout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/check-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: user.email, token: storedSession.token })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
localStorage.removeItem(VERIFICATION_STORAGE_KEY);
|
||||
await hardLogout();
|
||||
return;
|
||||
}
|
||||
|
||||
setCodeVerified(true);
|
||||
} catch {
|
||||
setCodeVerified(false);
|
||||
} finally {
|
||||
setVerificationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setVerificationLoading(true);
|
||||
void validateStoredSession();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.email) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
const storedSession = readStoredSession();
|
||||
if (!storedSession?.token || !storedSession.email || storedSession.email !== user.email?.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const expiresAt = Number(storedSession.expiresAt || 0);
|
||||
const createdAt = Number(storedSession.createdAt || 0);
|
||||
const absoluteExpiry = createdAt > 0 ? createdAt + ABSOLUTE_MAX_SESSION_MS : 0;
|
||||
const effectiveExpiry = expiresAt > 0 && absoluteExpiry > 0 ? Math.min(expiresAt, absoluteExpiry) : 0;
|
||||
|
||||
if (effectiveExpiry > 0 && now >= effectiveExpiry) {
|
||||
void hardLogout();
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
const refreshProfileStatus = async () => {
|
||||
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
|
||||
// Google-only accounts bypass the email-code/profile lookup on auth DB.
|
||||
setHasProfile(true);
|
||||
setProfileLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user?.email || !codeVerified) {
|
||||
setHasProfile(false);
|
||||
setProfileLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProfileLoading(true);
|
||||
const response = await fetch(`/api/auth/profile-status?email=${encodeURIComponent(user.email)}`);
|
||||
const payload = (await response.json().catch(() => null)) as { hasProfile?: boolean } | null;
|
||||
if (!response.ok) {
|
||||
setHasProfile(false);
|
||||
return;
|
||||
}
|
||||
setHasProfile(payload?.hasProfile === true);
|
||||
} catch {
|
||||
setHasProfile(false);
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshProfileStatus();
|
||||
}, [user, codeVerified]);
|
||||
|
||||
const loginWithGoogle = async () => {
|
||||
try {
|
||||
await signInWithPopup(auth, provider);
|
||||
} catch (caught) {
|
||||
const errorCode =
|
||||
typeof caught === 'object' && caught !== null && 'code' in caught ? String((caught as { code?: string }).code) : '';
|
||||
|
||||
// Fallback for browsers that block popups.
|
||||
if (errorCode === 'auth/popup-blocked') {
|
||||
await signInWithRedirect(auth, provider);
|
||||
return;
|
||||
}
|
||||
|
||||
throw caught;
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithEmail = async (email: string, password: string) => {
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
};
|
||||
|
||||
const registerWithEmail = async (email: string, password: string) => {
|
||||
return createUserWithEmailAndPassword(auth, email, password);
|
||||
};
|
||||
|
||||
const checkUsernameAvailability = async (username: string) => {
|
||||
const response = await fetch(`/api/auth/username-available?username=${encodeURIComponent(username)}`);
|
||||
const payload = (await response.json().catch(() => null)) as { available?: boolean; message?: string } | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Unable to check username.');
|
||||
}
|
||||
|
||||
return payload?.available === true;
|
||||
};
|
||||
|
||||
const registerProfile = async (params: { email: string; firebaseUid: string; username: string }) => {
|
||||
const response = await fetch('/api/auth/register-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message || 'Unable to register profile.');
|
||||
}
|
||||
|
||||
setHasProfile(true);
|
||||
};
|
||||
|
||||
const sendLoginCode = async () => {
|
||||
if (!user?.email) {
|
||||
throw new Error('No authenticated user.');
|
||||
}
|
||||
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
|
||||
throw new Error('Email code is only required for email/password accounts.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/send-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: user.email })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message || 'Unable to send code.');
|
||||
}
|
||||
};
|
||||
|
||||
const verifyLoginCode = async (code: string) => {
|
||||
if (!user?.email) {
|
||||
throw new Error('No authenticated user.');
|
||||
}
|
||||
if (isGoogleUser(user) && !usesPasswordAuth(user)) {
|
||||
throw new Error('Email code is not required for Google accounts.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/verify-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: user.email, code })
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { token?: string; message?: string; expiresAt?: number } | null;
|
||||
if (!response.ok || !payload?.token) {
|
||||
throw new Error(payload?.message || 'Unable to verify code.');
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
VERIFICATION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
email: user.email.toLowerCase(),
|
||||
token: payload.token,
|
||||
expiresAt: payload.expiresAt,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
);
|
||||
setCodeVerified(true);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await hardLogout();
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
loading,
|
||||
codeVerified,
|
||||
verificationLoading,
|
||||
hasProfile,
|
||||
profileLoading,
|
||||
loginWithEmail,
|
||||
registerWithEmail,
|
||||
checkUsernameAvailability,
|
||||
registerProfile,
|
||||
refreshProfileStatus,
|
||||
loginWithGoogle,
|
||||
sendLoginCode,
|
||||
verifyLoginCode,
|
||||
logout
|
||||
}),
|
||||
[codeVerified, hasProfile, loading, profileLoading, user, verificationLoading]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export interface CookieConsent {
|
||||
essential: boolean; // Always true
|
||||
preferences: boolean; // Language, Theme
|
||||
analytics: boolean; // Optional future use
|
||||
preferences: boolean; // Theme, UI preferences
|
||||
analytics: boolean; // Measurement / analytics
|
||||
}
|
||||
|
||||
interface CookieContextType {
|
||||
@@ -16,12 +16,34 @@ interface CookieContextType {
|
||||
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 }) => {
|
||||
@@ -29,36 +51,92 @@ export const CookieProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedConsent = localStorage.getItem('cookie_consent');
|
||||
if (savedConsent) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConsent);
|
||||
setConsent({ ...defaultConsent, ...parsed, essential: true });
|
||||
setHasInteracted(true);
|
||||
setShowBanner(false);
|
||||
} catch (e) {
|
||||
// Invalid json, treat as no consent
|
||||
setShowBanner(true);
|
||||
}
|
||||
} else {
|
||||
setShowBanner(true);
|
||||
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 = (newConsent: CookieConsent) => {
|
||||
localStorage.setItem('cookie_consent', JSON.stringify(newConsent));
|
||||
setConsent(newConsent);
|
||||
setHasInteracted(true);
|
||||
setShowBanner(false);
|
||||
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) => {
|
||||
saveConsent({ ...newConsent, essential: true });
|
||||
void saveConsent({ ...newConsent, essential: true });
|
||||
};
|
||||
|
||||
const acceptAll = () => {
|
||||
saveConsent({
|
||||
void saveConsent({
|
||||
essential: true,
|
||||
preferences: true,
|
||||
analytics: true,
|
||||
@@ -66,7 +144,7 @@ export const CookieProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
};
|
||||
|
||||
const rejectAll = () => {
|
||||
saveConsent({
|
||||
void saveConsent({
|
||||
essential: true,
|
||||
preferences: false,
|
||||
analytics: false,
|
||||
|
||||
26
frontend/src/lib/firebase.ts
Normal file
26
frontend/src/lib/firebase.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAnalytics, isSupported } from 'firebase/analytics';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY || 'AIzaSyD4xutY0szoiDzkkx-au2oZgvVGYzlplBw',
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || 'xeewy-site.firebaseapp.com',
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || 'xeewy-site',
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || 'xeewy-site.firebasestorage.app',
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '189588204447',
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID || '1:189588204447:web:fb32b4a26ca6bb3e196baa',
|
||||
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID || 'G-B358NGWK75'
|
||||
};
|
||||
|
||||
export const firebaseApp = initializeApp(firebaseConfig);
|
||||
export const auth = getAuth(firebaseApp);
|
||||
|
||||
let analyticsStarted = false;
|
||||
|
||||
export const initFirebaseAnalytics = async () => {
|
||||
if (analyticsStarted || typeof window === 'undefined') return null;
|
||||
const supported = await isSupported();
|
||||
if (!supported) return null;
|
||||
analyticsStarted = true;
|
||||
return getAnalytics(firebaseApp);
|
||||
};
|
||||
136
frontend/src/styles/components/_auth.scss
Normal file
136
frontend/src/styles/components/_auth.scss
Normal file
@@ -0,0 +1,136 @@
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
.auth-page {
|
||||
min-height: calc(100vh - 180px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 120px 20px 40px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.is-active {
|
||||
border-color: $primary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(135deg, #0d3c9f, #2457c9);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-rules {
|
||||
list-style: none;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
li {
|
||||
&.ok {
|
||||
color: #0f9f61;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.google-auth {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
|
||||
&--provider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.google-auth__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-message {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&--error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--success {
|
||||
color: #0f9f61;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-back-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: underline;
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -21,20 +21,32 @@
|
||||
}
|
||||
|
||||
.nav {
|
||||
@include flex-center();
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
max-width: map-get($breakpoints, xl);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
column-gap: 14px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 14px clamp(24px, 4vw, 72px);
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
justify-self: start;
|
||||
min-width: 0;
|
||||
|
||||
a {
|
||||
font-size: map-get($font-sizes, xl);
|
||||
font-size: clamp(1.3rem, 2.1vw, map-get($font-sizes, xl));
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
@include gradient-text();
|
||||
@include transition();
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
max-width: 44vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
@@ -42,63 +54,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
a {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
@include link-hover();
|
||||
}
|
||||
|
||||
&.mobile-menu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: calc(100vh - 80px);
|
||||
background: var(--bg-primary);
|
||||
flex-direction: column;
|
||||
padding: 40px 20px;
|
||||
gap: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
@include box-shadow(large);
|
||||
@include transition(transform opacity);
|
||||
|
||||
&.open {
|
||||
top: 80px;
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:not(.open) {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: map-get($font-sizes, lg);
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
.nav-spacer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
justify-self: end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.theme-toggle,
|
||||
.menu-toggle,
|
||||
.language-toggle {
|
||||
.language-toggle,
|
||||
.auth-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
min-height: auto;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
@include transition();
|
||||
@@ -127,30 +103,202 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
.auth-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: map-get($font-sizes, sm);
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
min-width: 110px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@include respond-to(md) {
|
||||
display: block;
|
||||
.auth-toggle--logout {
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(220, 38, 38, 0.35);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-menu {
|
||||
@include respond-to(md) {
|
||||
display: none;
|
||||
.auth-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-drawer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
@include transition(max-height opacity);
|
||||
|
||||
&.open {
|
||||
max-height: 86px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-drawer-list {
|
||||
margin: 0;
|
||||
padding: 14px clamp(24px, 4vw, 72px);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(12px, 1.1vw, 22px);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
|
||||
a {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.96rem;
|
||||
padding: 4px 0;
|
||||
white-space: nowrap;
|
||||
@include link-hover();
|
||||
|
||||
&.is-active {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@include respond-to(md) {
|
||||
@media (max-width: 1400px) {
|
||||
.header {
|
||||
.nav {
|
||||
padding: 12px 16px;
|
||||
padding: 12px clamp(14px, 2.4vw, 30px);
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
display: flex !important;
|
||||
.auth-toggle {
|
||||
min-width: 36px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.auth-toggle-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-drawer-list {
|
||||
padding: 10px clamp(14px, 2.4vw, 30px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1401px) {
|
||||
.header {
|
||||
.logout-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.header {
|
||||
.nav {
|
||||
padding: 12px 14px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
max-width: 58vw;
|
||||
}
|
||||
|
||||
.language-toggle .language-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-toggle,
|
||||
.language-toggle {
|
||||
min-width: 36px;
|
||||
padding: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-drawer {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(100vh - 64px);
|
||||
border-bottom: none;
|
||||
transform: translateX(100%);
|
||||
max-height: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@include transition(transform opacity);
|
||||
|
||||
&.open {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-drawer-list {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
padding: 18px 16px;
|
||||
|
||||
li {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 14px 0;
|
||||
font-size: map-get($font-sizes, lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
@use 'components/skills';
|
||||
@use 'components/projects';
|
||||
@use 'components/education';
|
||||
@use 'components/contact';
|
||||
@use 'components/contact';
|
||||
@use 'components/auth';
|
||||
|
||||
Reference in New Issue
Block a user