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

BIN
.DS_Store vendored

Binary file not shown.

BIN
backend/.DS_Store vendored

Binary file not shown.

6
backend/.gitignore vendored
View File

@@ -1,4 +1,6 @@
node_modules
.env
.env.example
.env.local
.env.local
dist
.env.example

View File

@@ -12,12 +12,17 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"mysql2": "^3.11.3"
"express-rate-limit": "^8.2.1",
"express-slow-down": "^3.0.1",
"helmet": "^8.1.0",
"mysql2": "^3.11.3",
"nodemailer": "^8.0.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.8.6",
"@types/nodemailer": "^7.0.11",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
@@ -166,6 +171,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -608,6 +623,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -649,6 +665,39 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-slow-down": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/express-slow-down/-/express-slow-down-3.0.1.tgz",
"integrity": "sha512-Syl+K1o/z8zSTsMWbDpJEOF5WzSj923ktkv/WbNq6E3tmNwPoEcKIhqRxMLWq3a/4OudRciylEqEZvzMBdzn8w==",
"license": "MIT",
"dependencies": {
"express-rate-limit": "8"
},
"engines": {
"node": ">= 16"
},
"peerDependencies": {
"express": "4 || 5 || ^5.0.0-beta.1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -827,6 +876,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -872,6 +930,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1106,6 +1173,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",

View File

@@ -15,14 +15,19 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"mysql2": "^3.11.3"
"express-rate-limit": "^8.2.1",
"express-slow-down": "^3.0.1",
"helmet": "^8.1.0",
"mysql2": "^3.11.3",
"nodemailer": "^8.0.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.8.6",
"@types/nodemailer": "^7.0.11",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}
}

View File

@@ -0,0 +1,18 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const authPool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_AUTH_USER,
password: process.env.DB_AUTH_PASSWORD,
database: process.env.DB_AUTH_NAME,
port: Number(process.env.DB_PORT) || 3306,
connectTimeout: Number(process.env.DB_CONNECT_TIMEOUT_MS) || 3500,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
export default authPool;

View File

@@ -9,6 +9,7 @@ const pool = mysql.createPool({
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: Number(process.env.DB_PORT) || 3306,
connectTimeout: Number(process.env.DB_CONNECT_TIMEOUT_MS) || 3500,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0

View File

@@ -2,20 +2,46 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import pool from './config/db';
import {
authLimiter,
authSlowDown,
globalLimiter,
requestTimeout,
securityHeaders
} from './middlewares/security';
import messagesRouter from './routes/messages';
import authCodeRouter from './routes/authCode';
import supportRouter from './routes/support';
import cookieConsentRouter from './routes/cookieConsent';
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
const allowedOrigins = String(process.env.CORS_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.use(cors());
app.use(express.json());
app.set('trust proxy', 1);
app.use(securityHeaders);
app.use(globalLimiter);
app.use(
cors({
origin: allowedOrigins.length > 0 ? allowedOrigins : true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: false
})
);
app.use(express.json({ limit: '20kb' }));
app.use(express.urlencoded({ extended: false, limit: '20kb' }));
app.use(requestTimeout(15_000));
app.use('/api/messages', messagesRouter);
import supportRouter from './routes/support';
app.use('/api/support', supportRouter);
app.use('/api/auth', requestTimeout(8_000), authSlowDown, authLimiter, authCodeRouter);
app.use('/api/cookies', requestTimeout(8_000), cookieConsentRouter);
// Basic health check
app.get('/api/health', (req, res) => {

View File

@@ -0,0 +1,70 @@
import { Request, Response, NextFunction } from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import slowDown from 'express-slow-down';
export const securityHeaders = helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' }
});
export const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: {
status: 'error',
message: 'Trop de requêtes. Réessayez plus tard.'
}
});
export const authLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: {
status: 'error',
message: 'Trop de tentatives dauthentification. Réessayez plus tard.'
}
});
export const verifyCodeLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: {
status: 'error',
message: 'Trop de tentatives de validation de code.'
}
});
export const authSlowDown = slowDown({
windowMs: 10 * 60 * 1000,
delayAfter: 5,
delayMs: (hits) => Math.min(1000 * (hits - 5), 10000),
validate: { delayMs: false }
});
export const verifyCodeSlowDown = slowDown({
windowMs: 10 * 60 * 1000,
delayAfter: 3,
delayMs: (hits) => Math.min(1500 * (hits - 3), 12000),
validate: { delayMs: false }
});
export const requestTimeout = (timeoutMs: number) => {
return (req: Request, res: Response, next: NextFunction) => {
req.setTimeout(timeoutMs);
res.setTimeout(timeoutMs, () => {
if (!res.headersSent) {
res.status(408).json({
status: 'error',
message: 'Délai de requête dépassé.'
});
}
});
next();
};
};

View File

@@ -0,0 +1,404 @@
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { ResultSetHeader, RowDataPacket } from 'mysql2';
import authPool from '../config/authDb';
import { verifyCodeLimiter, verifyCodeSlowDown } from '../middlewares/security';
const router = Router();
const CODE_TTL_MS = 10 * 60 * 1000;
const SESSION_TTL_HOURS = Math.min(Number(process.env.AUTH_SESSION_TTL_HOURS || 12), 24);
const SESSION_TTL_MS = SESSION_TTL_HOURS * 60 * 60 * 1000;
const RESEND_COOLDOWN_MS = 60 * 1000;
const MAX_ATTEMPTS = 5;
const HASH_PEPPER = process.env.AUTH_HASH_PEPPER || 'change-this-pepper';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const usernameRegex = /^(?=.{3,24}$)[a-zA-Z0-9._-]+$/;
const normalizeEmail = (email: string) => email.trim().toLowerCase();
const hashCode = (email: string, code: string) => {
return crypto.createHash('sha256').update(`${email}:${code}:${HASH_PEPPER}`).digest('hex');
};
const normalizeUsername = (username: string) => username.trim().toLowerCase();
const hashSessionToken = (token: string) => {
return crypto.createHash('sha256').update(`${token}:${HASH_PEPPER}`).digest('hex');
};
const resolveAuthDbError = (error: any) => {
const code = String(error?.code || '');
if (code === 'ETIMEDOUT' || code === 'ECONNREFUSED' || code === 'PROTOCOL_CONNECTION_LOST') {
return { status: 503, message: 'Base auth indisponible.' };
}
if (code === 'ER_ACCESS_DENIED_ERROR' || code === 'ER_BAD_DB_ERROR') {
return { status: 503, message: 'Configuration DB auth invalide.' };
}
if (code === 'ER_NO_SUCH_TABLE') {
return { status: 503, message: 'Tables auth manquantes.' };
}
return null;
};
const cleanupExpiredEntries = async () => {
await authPool.query('DELETE FROM auth_code_challenges WHERE expires_at <= NOW() OR consumed_at IS NOT NULL');
await authPool.query('DELETE FROM auth_verified_sessions WHERE expires_at <= NOW() OR revoked_at IS NOT NULL');
};
const getTransporter = () => {
const host = process.env.MAIL_HOST;
const port = Number(process.env.MAIL_PORT || 587);
const secure = String(process.env.MAIL_SECURE || 'false') === 'true';
const user = process.env.MAIL_USER;
const pass = process.env.MAIL_PASS;
if (!host || !user || !pass) {
return null;
}
return nodemailer.createTransport({
host,
port,
secure,
connectionTimeout: Number(process.env.MAIL_CONNECTION_TIMEOUT_MS) || 4000,
greetingTimeout: Number(process.env.MAIL_GREETING_TIMEOUT_MS) || 4000,
socketTimeout: Number(process.env.MAIL_SOCKET_TIMEOUT_MS) || 6000,
auth: {
user,
pass
}
});
};
router.post('/send-code', async (req: Request, res: Response): Promise<void> => {
const incomingEmail = String(req.body?.email || '');
const email = normalizeEmail(incomingEmail);
if (!email || !emailRegex.test(email)) {
res.status(400).json({ status: 'error', message: 'Email invalide.' });
return;
}
const transporter = getTransporter();
if (!transporter) {
res.status(500).json({ status: 'error', message: 'SMTP non configuré.' });
return;
}
try {
await cleanupExpiredEntries();
const [existingRows] = await authPool.query<RowDataPacket[]>(
`SELECT id, last_sent_at
FROM auth_code_challenges
WHERE email = ? AND consumed_at IS NULL AND expires_at > NOW()
ORDER BY id DESC
LIMIT 1`,
[email]
);
if (existingRows.length > 0) {
const lastSent = new Date(existingRows[0].last_sent_at as string | Date).getTime();
const remainingMs = RESEND_COOLDOWN_MS - (Date.now() - lastSent);
if (remainingMs > 0) {
res.status(429).json({
status: 'error',
message: 'Veuillez patienter avant de demander un nouveau code.',
retryAfterSeconds: Math.ceil(remainingMs / 1000)
});
return;
}
}
const code = String(crypto.randomInt(100000, 1000000));
const codeHash = hashCode(email, code);
const fromAddress = process.env.MAIL_FROM || process.env.MAIL_USER || 'no-reply@xeewy.be';
const subject = process.env.MAIL_AUTH_SUBJECT || 'Votre code de verification';
const text = `Votre code de verification est: ${code}\nCe code expire dans 10 minutes.`;
await transporter.sendMail({
from: fromAddress,
to: email,
subject,
text
});
await authPool.query(
`INSERT INTO auth_code_challenges (email, code_hash, attempts, expires_at, last_sent_at)
VALUES (?, ?, 0, DATE_ADD(NOW(), INTERVAL ? SECOND), NOW())`,
[email, codeHash, Math.floor(CODE_TTL_MS / 1000)]
);
res.json({ status: 'success', message: 'Code envoyé.' });
} catch (error) {
console.error('Failed to send auth code email:', error);
res.status(500).json({ status: 'error', message: "Impossible d'envoyer le code." });
}
});
router.get('/username-available', async (req: Request, res: Response): Promise<void> => {
const incomingUsername = String(req.query.username || '');
const username = normalizeUsername(incomingUsername);
if (!usernameRegex.test(username)) {
res.status(400).json({
status: 'error',
message: 'Pseudo invalide (3-24, lettres/chiffres/._-).'
});
return;
}
try {
const [rows] = await authPool.query<RowDataPacket[]>(
'SELECT id FROM auth_users WHERE username = ? LIMIT 1',
[username]
);
res.json({
status: 'success',
available: rows.length === 0
});
} catch (error) {
console.error('Failed to check username availability:', error);
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
}
});
router.get('/profile-status', async (req: Request, res: Response): Promise<void> => {
const incomingEmail = String(req.query.email || '');
const email = normalizeEmail(incomingEmail);
if (!email || !emailRegex.test(email)) {
res.status(400).json({ status: 'error', message: 'Email invalide.' });
return;
}
try {
const [rows] = await authPool.query<RowDataPacket[]>(
'SELECT id, username FROM auth_users WHERE email = ? LIMIT 1',
[email]
);
if (rows.length === 0) {
res.json({ status: 'success', hasProfile: false });
return;
}
res.json({
status: 'success',
hasProfile: true,
username: String(rows[0].username || '')
});
} catch (error) {
console.error('Failed to get profile status:', error);
const dbError = resolveAuthDbError(error);
if (dbError) {
res.status(dbError.status).json({ status: 'error', message: dbError.message });
return;
}
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
}
});
router.post('/register-profile', async (req: Request, res: Response): Promise<void> => {
const incomingEmail = String(req.body?.email || '');
const incomingUid = String(req.body?.firebaseUid || '');
const incomingUsername = String(req.body?.username || '');
const email = normalizeEmail(incomingEmail);
const firebaseUid = incomingUid.trim();
const username = normalizeUsername(incomingUsername);
if (!email || !emailRegex.test(email)) {
res.status(400).json({ status: 'error', message: 'Email invalide.' });
return;
}
if (!firebaseUid || firebaseUid.length < 10) {
res.status(400).json({ status: 'error', message: 'UID Firebase invalide.' });
return;
}
if (!usernameRegex.test(username)) {
res.status(400).json({
status: 'error',
message: 'Pseudo invalide (3-24, lettres/chiffres/._-).'
});
return;
}
try {
const [existingByEmail] = await authPool.query<RowDataPacket[]>(
'SELECT id, username FROM auth_users WHERE email = ? LIMIT 1',
[email]
);
if (existingByEmail.length > 0) {
const currentUsername = String(existingByEmail[0].username || '');
if (currentUsername === username) {
res.json({ status: 'success', message: 'Profil déjà enregistré.' });
return;
}
res.status(409).json({
status: 'error',
message: 'Un profil existe déjà pour cet email avec un autre pseudo.'
});
return;
}
await authPool.query<ResultSetHeader>(
'INSERT INTO auth_users (firebase_uid, email, username) VALUES (?, ?, ?)',
[firebaseUid, email, username]
);
res.status(201).json({ status: 'success', message: 'Profil enregistré.' });
} catch (error: any) {
if (error?.code === 'ER_DUP_ENTRY') {
res.status(409).json({ status: 'error', message: 'Ce pseudo est déjà utilisé.' });
return;
}
console.error('Failed to register auth profile:', error);
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
}
});
router.post('/verify-code', verifyCodeSlowDown, verifyCodeLimiter, async (req: Request, res: Response): Promise<void> => {
const incomingEmail = String(req.body?.email || '');
const incomingCode = String(req.body?.code || '').trim();
const email = normalizeEmail(incomingEmail);
if (!email || !emailRegex.test(email) || !/^\d{6}$/.test(incomingCode)) {
res.status(400).json({ status: 'error', message: 'Données invalides.' });
return;
}
try {
await cleanupExpiredEntries();
const [rows] = await authPool.query<RowDataPacket[]>(
`SELECT id, code_hash, attempts, expires_at
FROM auth_code_challenges
WHERE email = ? AND consumed_at IS NULL
ORDER BY id DESC
LIMIT 1`,
[email]
);
if (rows.length === 0) {
res.status(404).json({ status: 'error', message: 'Aucun code actif pour cet email.' });
return;
}
const challenge = rows[0];
const challengeId = Number(challenge.id);
const attempts = Number(challenge.attempts || 0);
const expiresAt = new Date(challenge.expires_at as string | Date).getTime();
const storedHash = String(challenge.code_hash || '');
if (expiresAt <= Date.now()) {
await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]);
res.status(410).json({ status: 'error', message: 'Code expiré.' });
return;
}
if (attempts >= MAX_ATTEMPTS) {
await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]);
res.status(429).json({ status: 'error', message: 'Trop de tentatives.' });
return;
}
const providedHash = hashCode(email, incomingCode);
const validCode = crypto.timingSafeEqual(Buffer.from(storedHash), Buffer.from(providedHash));
if (!validCode) {
await authPool.query('UPDATE auth_code_challenges SET attempts = attempts + 1 WHERE id = ?', [challengeId]);
res.status(401).json({ status: 'error', message: 'Code invalide.' });
return;
}
await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]);
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = hashSessionToken(token);
await authPool.query(
`INSERT INTO auth_verified_sessions (token_hash, email, expires_at)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`,
[tokenHash, email, Math.floor(SESSION_TTL_MS / 1000)]
);
res.json({
status: 'success',
token,
expiresAt: Date.now() + SESSION_TTL_MS
});
} catch (error) {
console.error('Failed to verify code:', error);
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
}
});
router.post('/check-session', async (req: Request, res: Response): Promise<void> => {
const incomingEmail = String(req.body?.email || '');
const token = String(req.body?.token || '').trim();
const email = normalizeEmail(incomingEmail);
if (!email || !emailRegex.test(email) || !token) {
res.status(400).json({ status: 'error', message: 'Données invalides.' });
return;
}
try {
await cleanupExpiredEntries();
const tokenHash = hashSessionToken(token);
const [rows] = await authPool.query<RowDataPacket[]>(
`SELECT id
FROM auth_verified_sessions
WHERE token_hash = ? AND email = ? AND revoked_at IS NULL AND expires_at > NOW()
LIMIT 1`,
[tokenHash, email]
);
if (rows.length === 0) {
res.status(401).json({ status: 'error', message: 'Session invalide.' });
return;
}
res.json({ status: 'success' });
} catch (error) {
console.error('Failed to check session:', error);
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
}
});
router.post('/logout-session', async (req: Request, res: Response): Promise<void> => {
const incomingEmail = String(req.body?.email || '');
const token = String(req.body?.token || '').trim();
const email = normalizeEmail(incomingEmail);
if (!email || !emailRegex.test(email) || !token) {
res.status(400).json({ status: 'error', message: 'Données invalides.' });
return;
}
try {
const tokenHash = hashSessionToken(token);
await authPool.query(
`UPDATE auth_verified_sessions
SET revoked_at = NOW()
WHERE token_hash = ? AND email = ? AND revoked_at IS NULL`,
[tokenHash, email]
);
res.json({ status: 'success' });
} catch (error) {
console.error('Failed to revoke session:', error);
res.status(500).json({ status: 'error', message: 'Erreur serveur.' });
}
});
export default router;

View File

@@ -0,0 +1,129 @@
import { Request, Response, Router } from 'express';
import crypto from 'crypto';
const router = Router();
const CONSENT_VERSION = 2;
const CONSENT_TTL_SECONDS = 180 * 24 * 60 * 60;
type ConsentPayload = {
essential: boolean;
preferences: boolean;
analytics: boolean;
};
const getSecret = () => {
return process.env.CONSENT_COOKIE_SECRET || process.env.AUTH_HASH_PEPPER || '';
};
const base64UrlEncode = (value: string) => {
return Buffer.from(value, 'utf-8').toString('base64url');
};
const base64UrlDecode = (value: string) => {
return Buffer.from(value, 'base64url').toString('utf-8');
};
const signValue = (payloadB64: string, secret: string) => {
return crypto.createHmac('sha512', secret).update(payloadB64).digest('hex');
};
const isValidConsent = (consent: any): consent is ConsentPayload => {
return (
typeof consent === 'object' &&
consent !== null &&
typeof consent.essential === 'boolean' &&
typeof consent.preferences === 'boolean' &&
typeof consent.analytics === 'boolean'
);
};
router.post('/sign-consent', (req: Request, res: Response): void => {
const secret = getSecret();
if (!secret || secret.length < 32) {
res.status(500).json({ status: 'error', message: 'CONSENT_COOKIE_SECRET manquant ou trop court.' });
return;
}
const consent = req.body?.consent;
if (!isValidConsent(consent)) {
res.status(400).json({ status: 'error', message: 'Consent payload invalide.' });
return;
}
const now = Math.floor(Date.now() / 1000);
const payload = {
consent: {
essential: true,
preferences: Boolean(consent.preferences),
analytics: Boolean(consent.analytics)
},
version: CONSENT_VERSION,
iat: now,
exp: now + CONSENT_TTL_SECONDS,
nonce: crypto.randomBytes(16).toString('hex')
};
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
const signature = signValue(payloadB64, secret);
const token = `${payloadB64}.${signature}`;
res.json({
status: 'success',
token,
maxAgeSeconds: CONSENT_TTL_SECONDS
});
});
router.post('/verify-consent', (req: Request, res: Response): void => {
const secret = getSecret();
if (!secret || secret.length < 32) {
res.status(500).json({ status: 'error', message: 'CONSENT_COOKIE_SECRET manquant ou trop court.' });
return;
}
const token = String(req.body?.token || '');
const [payloadB64, providedSignature] = token.split('.');
if (!payloadB64 || !providedSignature) {
res.json({ status: 'success', valid: false });
return;
}
try {
const expectedSignature = signValue(payloadB64, secret);
if (
expectedSignature.length !== providedSignature.length ||
!crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(providedSignature))
) {
res.json({ status: 'success', valid: false });
return;
}
const payload = JSON.parse(base64UrlDecode(payloadB64)) as {
consent?: ConsentPayload;
version?: number;
exp?: number;
};
if (!payload || payload.version !== CONSENT_VERSION || !isValidConsent(payload.consent)) {
res.json({ status: 'success', valid: false });
return;
}
const now = Math.floor(Date.now() / 1000);
if (!payload.exp || payload.exp <= now) {
res.json({ status: 'success', valid: false });
return;
}
res.json({
status: 'success',
valid: true,
consent: payload.consent
});
} catch {
res.json({ status: 'success', valid: false });
}
});
export default router;

View File

@@ -7,7 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xeewy.be</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://localhost:* http://localhost:* https://api.emailjs.com;">
content="default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.google.com https://www.gstatic.com https://www.googletagmanager.com https://www.google.com;
script-src-elem 'self' 'unsafe-inline' https://apis.google.com https://www.gstatic.com https://www.googletagmanager.com https://www.google.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' data: https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' ws://localhost:* http://localhost:* https://api.emailjs.com https://*.googleapis.com https://*.firebaseio.com https://*.gstatic.com https://www.googletagmanager.com https://www.google-analytics.com https://region1.google-analytics.com;
frame-src 'self' https://accounts.google.com https://*.google.com https://*.firebaseapp.com;">
</head>
<body>
@@ -15,4 +23,4 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@emailjs/browser": "^4.4.1",
"axios": "^1.13.2",
"firebase": "^12.9.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.553.0",
"react": "^19.2.0",

BIN
frontend/public/google.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

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

View File

@@ -7,6 +7,12 @@ export default defineConfig({
server: {
hmr: {
overlay: false
},
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})