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:
Van Leemput Dayron
2026-02-24 08:25:37 +01:00
parent d3e0570214
commit 46e6edcd9c
30 changed files with 3469 additions and 324 deletions

View File

@@ -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 />

View File

@@ -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 dinterface.',
analytics: 'Analytiques',
analyticsDesc: 'Statistiques anonymes',
analyticsDesc: 'Mesure daudience (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>
);
};

View File

@@ -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;
}
}
}

View File

@@ -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;

View 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 à laccueil' : 'Back to home'}
</Link>
</div>
</section>
);
};
export default Login;

View 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;

View 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;

View 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 denvoyer 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 denvoyer 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 à laccueil' : 'Back to home'}
</Link>
</div>
</section>
);
};
export default VerifyAuthCode;

View 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;
};

View File

@@ -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,

View 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);
};

View 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;
}

View File

@@ -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);
}
}
}
}

View File

@@ -12,4 +12,5 @@
@use 'components/skills';
@use 'components/projects';
@use 'components/education';
@use 'components/contact';
@use 'components/contact';
@use 'components/auth';