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