diff --git a/.DS_Store b/.DS_Store index dfda25d..feac0d7 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/.DS_Store b/backend/.DS_Store index 525ee8d..22176a9 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/.gitignore b/backend/.gitignore index 0c491f6..9498b19 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,6 @@ node_modules .env -.env.example -.env.local \ No newline at end of file +.env.local +dist + +.env.example \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 402f4ce..81eeb7f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 2ad7760..06936a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/backend/src/config/authDb.ts b/backend/src/config/authDb.ts new file mode 100644 index 0000000..e69794d --- /dev/null +++ b/backend/src/config/authDb.ts @@ -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; diff --git a/backend/src/config/db.ts b/backend/src/config/db.ts index 4a76168..60456e3 100644 --- a/backend/src/config/db.ts +++ b/backend/src/config/db.ts @@ -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 diff --git a/backend/src/index.ts b/backend/src/index.ts index dfac28c..c23ec12 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) => { diff --git a/backend/src/middlewares/security.ts b/backend/src/middlewares/security.ts new file mode 100644 index 0000000..ded7f95 --- /dev/null +++ b/backend/src/middlewares/security.ts @@ -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(); + }; +}; diff --git a/backend/src/routes/authCode.ts b/backend/src/routes/authCode.ts new file mode 100644 index 0000000..7332ad8 --- /dev/null +++ b/backend/src/routes/authCode.ts @@ -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 => { + 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( + `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 => { + 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( + '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 => { + 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( + '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 => { + 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( + '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( + '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 => { + 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( + `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 => { + 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( + `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 => { + 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; diff --git a/backend/src/routes/cookieConsent.ts b/backend/src/routes/cookieConsent.ts new file mode 100644 index 0000000..2c3a96e --- /dev/null +++ b/backend/src/routes/cookieConsent.ts @@ -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; diff --git a/frontend/index.html b/frontend/index.html index 1c926e9..9ec5af0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,15 @@ xeewy.be + 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;"> @@ -15,4 +23,4 @@ - \ No newline at end of file + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0285b15..a9d9d5d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,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", @@ -923,6 +924,649 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@firebase/ai": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.8.0.tgz", + "integrity": "sha512-grWYGFPsSo+pt+6CYeKR0kWnUfoLLS3xgWPvNrhAS5EPxl6xWq7+HjDZqX24yLneETyl45AVgDsTbVgxeWeRfg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.19", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.19.tgz", + "integrity": "sha512-3wU676fh60gaiVYQEEXsbGS4HbF2XsiBphyvvqDbtC1U4/dO4coshbYktcCHq+HFaGIK07iHOh4pME0hEq1fcg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.25.tgz", + "integrity": "sha512-fdzoaG0BEKbqksRDhmf4JoyZf16Wosrl0Y7tbZtJyVDOOwziE0vrFjmZuTdviL0yhak+Nco6rMsUUbkbD+qb6Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.19", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.14.8", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.8.tgz", + "integrity": "sha512-WiE9uCGRLUnShdjb9iP20sA3ToWrBbNXr14/N5mow7Nls9dmKgfGaGX5cynLvrltxq2OrDLh1VDNaUgsnS/k/g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.0.tgz", + "integrity": "sha512-XAvALQayUMBJo58U/rxW02IhsesaxxfWVmVkauZvGEz3vOAjMEQnzFlyblqkc2iAaO82uJ2ZVyZv9XzPfxjJ6w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.0.tgz", + "integrity": "sha512-UfK2Q8RJNjYM/8MFORltZRG9lJj11k0nW84rrffiKvcJxLf1jf6IEjCIkCamykHE73C6BwqhVfhIBs69GXQV0g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.11.0", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.8.tgz", + "integrity": "sha512-4De6SUZ36zozl9kh5rZSxKWULpgty27rMzZ6x+xkoo7+NWyhWyFdsdvhFsWhTw/9GGj0wXIcbTjwHYCUIUuHyg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/app": "0.14.8", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@firebase/auth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.12.0.tgz", + "integrity": "sha512-zkvLpsrxynWHk07qGrUDfCSqKf4AvfZGEqJ7mVCtYGjNNDbGE71k0Yn84rg8QEZu4hQw1BC0qDEHzpNVBcSVmA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.2.tgz", + "integrity": "sha512-8UhCzF6pav9bw/eXA8Zy1QAKssPRYEYXaWagie1ewLTwHkXv6bKp/j6/IwzSYQP67sy/BMFXIFaCCsoXzFLr7A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.12.0", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.12.tgz", + "integrity": "sha512-baPddcoNLj/+vYo+HSJidJUdr5W4OkhT109c5qhR8T1dJoZcyJpkv/dFpYlw/VJ3dV66vI8GHQFrmAZw/xUS4g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.11.0.tgz", + "integrity": "sha512-Zb88s8rssBd0J2Tt+NUXMPt2sf+Dq7meatKiJf5t9oto1kZ8w9gK59Koe1uPVbaKfdgBp++N/z0I4G/HamyEhg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.5.tgz", + "integrity": "sha512-yVX1CkVvqBI4qbA56uZo42xFA4TNU0ICQ+9AFDvYq9U9Xu6iAx9lFDAk/tN+NGereQQXXCSnpISwc/oxsQqPLA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/firestore": "4.11.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.1.tgz", + "integrity": "sha512-sUeWSb0rw5T+6wuV2o9XNmh9yHxjFI9zVGFnjFi+n7drTEWpl7ZTz1nROgGrSu472r+LAaj+2YaSicD4R8wfbw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.1.tgz", + "integrity": "sha512-AxxUBXKuPrWaVNQ8o1cG1GaCAtXT8a0eaTDfqgS5VsRYLAR0ALcfqDLwo/QyijZj1w8Qf8n3Qrfy/+Im245hOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/functions": "0.13.1", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.19", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.19.tgz", + "integrity": "sha512-nGDmiwKLI1lerhwfwSHvMR9RZuIH5/8E3kgUWnVRqqL7kGVSktjLTWEMva7oh5yxQ3zXfIlIwJwMcaM5bK5j8Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.19", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.19.tgz", + "integrity": "sha512-khfzIY3EI5LePePo7vT19/VEIH1E3iYsHknI/6ek9T8QCozAZshWT9CjlwOzZrKvTHMeNcbpo/VSOSIWDSjWdQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.23", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.23.tgz", + "integrity": "sha512-cfuzv47XxqW4HH/OcR5rM+AlQd1xL/VhuaeW/wzMW1LFrsFcTn0GND/hak1vkQc2th8UisBcrkVcQAnOnKwYxg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.23.tgz", + "integrity": "sha512-SN857v/kBUvlQ9X/UjAqBoQ2FEaL1ZozpnmL1ByTe57iXkmnVVFm9KqAsTfmf+OEwWI4kJJe9NObtN/w22lUgg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/messaging": "0.12.23", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.9.tgz", + "integrity": "sha512-UzybENl1EdM2I1sjYm74xGt/0JzRnU/0VmfMAKo2LSpHJzaj77FCLZXmYQ4oOuE+Pxtt8Wy2BVJEENiZkaZAzQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.22.tgz", + "integrity": "sha512-xLKxaSAl/FVi10wDX/CHIYEUP13jXUjinL+UaNXT9ByIvxII5Ne5150mx6IgM8G6Q3V+sPiw9C8/kygkyHUVxg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.9", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.0.tgz", + "integrity": "sha512-sJz7C2VACeE257Z/3kY9Ap2WXbFsgsDLfaGfZmmToKAK39ipXxFan+vzB9CSbF6mP7bzjyzEnqPcMXhAnYE6fQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.21", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.21.tgz", + "integrity": "sha512-9+lm0eUycxbu8GO25JfJe4s6R2xlDqlVt0CR6CvN9E6B4AFArEV4qfLoDVRgIEB7nHKwvH2nYRocPWfmjRQTnw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.8.0", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", + "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.0.tgz", + "integrity": "sha512-xWWbb15o6/pWEw8H01UQ1dC5U3rf8QTAzOChYyCpafV6Xki7KVp3Yaw2nSklUwHEziSWE9KoZJS7iYeyqWnYFA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.0.tgz", + "integrity": "sha512-vDzhgGczr1OfcOy285YAPur5pWDEvD67w4thyeCUh6Ys0izN9fNYtA1MJERmNBfqjqu0lg0FM5GLbw0Il21M+g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/storage": "0.14.0", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", + "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1373,6 +2017,70 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.43", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", @@ -1751,9 +2459,7 @@ "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2113,11 +2819,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2306,11 +3020,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2323,7 +3050,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2456,6 +3182,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2547,7 +3279,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2805,6 +3536,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2848,6 +3591,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.9.0.tgz", + "integrity": "sha512-CwwTYoqZg6KxygPOaaJqIc4aoLvo0RCRrXoln9GoxLE8QyAwTydBaSLGVlR4WPcuOgN3OEL0tJLT1H4IU/dv7w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.8.0", + "@firebase/analytics": "0.10.19", + "@firebase/analytics-compat": "0.2.25", + "@firebase/app": "0.14.8", + "@firebase/app-check": "0.11.0", + "@firebase/app-check-compat": "0.4.0", + "@firebase/app-compat": "0.5.8", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.12.0", + "@firebase/auth-compat": "0.6.2", + "@firebase/data-connect": "0.3.12", + "@firebase/database": "1.1.0", + "@firebase/database-compat": "2.1.0", + "@firebase/firestore": "4.11.0", + "@firebase/firestore-compat": "0.4.5", + "@firebase/functions": "0.13.1", + "@firebase/functions-compat": "0.4.1", + "@firebase/installations": "0.6.19", + "@firebase/installations-compat": "0.2.19", + "@firebase/messaging": "0.12.23", + "@firebase/messaging-compat": "0.2.23", + "@firebase/performance": "0.7.9", + "@firebase/performance-compat": "0.2.22", + "@firebase/remote-config": "0.8.0", + "@firebase/remote-config-compat": "0.2.21", + "@firebase/storage": "0.14.0", + "@firebase/storage-compat": "0.4.0", + "@firebase/util": "1.13.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2966,6 +3745,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3097,6 +3885,18 @@ "node": ">= 0.4" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3151,6 +3951,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3288,6 +4097,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3295,6 +4110,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3586,6 +4407,30 @@ "node": ">= 0.8.0" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3708,6 +4553,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3795,6 +4649,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/sass": { "version": "1.94.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.0.tgz", @@ -3872,6 +4746,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4035,7 +4935,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4187,6 +5086,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4213,6 +5141,32 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4220,6 +5174,33 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0ff9a7f..8144b15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/google.png b/frontend/public/google.png new file mode 100644 index 0000000..001e1a2 Binary files /dev/null and b/frontend/public/google.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c33a995..7a57753 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> - - + + + + + } /> @@ -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() { } /> } /> } /> - } /> + } /> } /> } /> } /> - } /> + } /> + } /> + } /> + } />