From fba80ae8949bd208cbf0b843dc236babd98f252e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 13:06:39 +0200 Subject: [PATCH 01/12] docs: add cookies banner & privacy page design spec --- ...026-08-26-cookies-banner-privacy-design.md | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-cookies-banner-privacy-design.md diff --git a/docs/superpowers/specs/2026-08-26-cookies-banner-privacy-design.md b/docs/superpowers/specs/2026-08-26-cookies-banner-privacy-design.md new file mode 100644 index 0000000..027e4d8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-cookies-banner-privacy-design.md @@ -0,0 +1,316 @@ +# Design: Cookies Banner & Privacy Page + +## Overview + +Add GDPR/RGPD-compliant cookie consent management, a health data consent flow, and a privacy policy page to FarmaFinder across both web and mobile platforms. The app collects significant personal data including health/medical data (TSI card scanning), requiring special category consent under GDPR Article 9. + +**Company/Data Controller:** Hacecalor S.L. + +**Languages:** Spanish (primary) + Catalan + +**Platforms:** Web frontend (apps/frontend) + Mobile frontend (apps/frontend-mobile) + +--- + +## Consent Categories + +| Category | Required | Default | Description | +|---|---|---|---| +| **essential** | Yes (always on) | ON | Session cookie (express-session, HTTP-only), CSRF, authentication | +| **analytics** | No (opt-in) | OFF | Grafana Faro (browser RUM, Web Vitals, error tracking) | +| **preferences** | No (opt-in) | OFF | Theme (dark/light), language (es/ca), saved searches | +| **health_data** | No (opt-in) | OFF | TSI card scanning, CIP code extraction, prescription lookup | + +**health_data consent** is shown in two places: +1. In the cookie banner (as a 4th toggle, like other categories) +2. Re-prompted via a dedicated modal when the user first attempts to scan their TSI card + +If the user already accepted health_data via the banner, the TSI modal is skipped. + +--- + +## Architecture + +**Approach:** Server-side consent + client sync + +- Consent stored in database (`user_consents` table) for logged-in users +- Consent cached in `localStorage` (web) or `expo-secure-store` (mobile) for fast access and anonymous users +- On login/register: Anonymous consents migrated to the user account +- Backend can enforce consent via middleware + +--- + +## Database Schema + +### New table: `user_consents` + +```sql +CREATE TABLE user_consents ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + session_id VARCHAR(255), + category VARCHAR(20) NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')), + granted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, category), + UNIQUE(session_id, category) +); + +CREATE INDEX idx_user_consents_user_id ON user_consents(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX idx_user_consents_session_id ON user_consents(session_id) WHERE session_id IS NOT NULL; +``` + +- `user_id` is NULL for anonymous users (consent tied to session) +- `session_id` is NULL for logged-in users (consent tied to account) +- `essential` category is always forced to `true` by the backend + +--- + +## Backend API + +### Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| `GET` | `/api/consents` | Optional | Get current user's/session's consents | +| `PUT` | `/api/consents` | Optional | Save consent preferences | + +#### GET /api/consents + +Response: +```json +{ + "essential": true, + "analytics": false, + "preferences": true, + "health_data": false +} +``` + +#### PUT /api/consents + +Request: +```json +{ + "categories": { + "analytics": true, + "preferences": false, + "health_data": true + } +} +``` + +Response: Same as GET (full consent state after save). + +- `essential` is always forced to `true` in the response, regardless of what is sent +- For logged-in users: saved with `user_id` +- For anonymous users: saved with `session_id` from express-session + +### Consent Migration on Login/Register + +When a user logs in or registers: +1. Fetch any consents stored under the current `session_id` +2. Merge with any existing `user_id` consents (user_id takes precedence for conflicts) +3. Delete session-based consents +4. Return merged consents + +### Middleware + +`requireConsent(category)` — checks if the current user/session has granted consent for the given category. Returns 403 if not granted. + +Applied to: +- `POST /api/tsi/scan` — requires `health_data` + +--- + +## Web Frontend (apps/frontend) + +### New Components + +#### CookieBanner.jsx + CookieBanner.css + +- Fixed banner at bottom of screen, shown on first visit (no consent record in localStorage) +- Brief explanation text +- 4 category toggles: Esenciales (locked ON), Analítica, Preferencias, Datos de Salud +- Buttons: "Aceptar todo" / "Rechazar opcionales" / "Guardar" +- "Más información" link → opens PrivacyView +- Styled with existing CSS custom properties (Material Design 3 tokens) +- Responsive: full-width on mobile, centered card on desktop + +#### HealthConsentModal.jsx + HealthConsentModal.css + +- Modal shown when user first taps TSI scanner (if health_data consent not yet granted) +- Explains what data is collected (CIP code, prescriptions) and how it's used +- Buttons: "Aceptar y escanear" / "Cancelar" +- Sets health_data consent on accept, then proceeds to scan +- Skipped if user previously accepted via banner + +#### PrivacyView.jsx + PrivacyView.css + +- Full privacy policy page, new screen in SPA routing +- Sections: Data Controller, Data Collected, Purpose, Legal Basis, External Services, Retention, User Rights, Contact, Cookie Policy +- Content in Spanish with Catalan toggle (using existing i18n pattern) +- Accessible via route `privacy` + +#### Footer / BottomNav update + +- Adds "Política de privacidad" link to existing navigation +- Opens PrivacyView + +### Consent Sync Utility + +- `utils/consent.js` — manages localStorage ↔ API sync +- On app load: fetch from API if logged in, else read localStorage +- On consent change: update localStorage + call PUT /api/consents +- Grafana Faro initialization gated on analytics consent + +### Grafana Faro Gating + +In `utils/faro.js`: +- Check consent before calling `init()` +- If analytics consent not granted, Faro is not initialized +- If consent is granted later (via banner), re-initialize Faro + +### TSI Scanner Gating + +In `ScannerView.jsx`: +- Before opening TSI scan, check health_data consent +- If not granted, show HealthConsentModal +- If granted, proceed directly to scan + +--- + +## Mobile Frontend (apps/frontend-mobile) + +### New Components + +#### components/CookieBanner.tsx + +- Bottom sheet / slide-up panel with same 4 categories +- Same toggle logic as web +- Buttons: "Aceptar todo" / "Rechazar opcionales" / "Guardar" +- Styled with ThemeProvider tokens (dark/light theme) +- Shown on first launch (checked via expo-secure-store key `consents_initialized`) + +#### components/HealthConsentModal.tsx + +- Same purpose as web: shown when user first tries to scan TSI +- Explains data collection, two buttons +- Sets consent via API, then proceeds to scanner +- Skipped if consent already granted + +#### app/privacy.tsx (or app/(tabs)/privacy.tsx) + +- New Expo Router screen for privacy policy +- Same content structure as web +- Uses ScrollView for long content +- Accessible from profile screen + +### Consent Sync Utility + +- `services/consent.ts` — manages expo-secure-store ↔ API sync +- On app load: fetch from API if logged in, else read secure-store +- On consent change: update secure-store + call PUT /api/consents + +### API Client Additions + +- `services/api.ts` gets: `getConsents()`, `updateConsents(categories)` +- Same endpoints as web + +### TSI Scanner Gating + +In scanner flow: +- Before opening TSI scan, check health_data consent +- If not granted, show HealthConsentModal +- If granted, proceed directly + +--- + +## i18n Keys + +### Cookie Banner +- `cookie_banner.title` +- `cookie_banner.description` +- `cookie_banner.category.essential` +- `cookie_banner.category.essential_desc` +- `cookie_banner.category.analytics` +- `cookie_banner.category.analytics_desc` +- `cookie_banner.category.preferences` +- `cookie_banner.category.preferences_desc` +- `cookie_banner.category.health_data` +- `cookie_banner.category.health_data_desc` +- `cookie_banner.accept_all` +- `cookie_banner.reject_optional` +- `cookie_banner.save` +- `cookie_banner.more_info` + +### Health Consent Modal +- `health_consent.title` +- `health_consent.description` +- `health_consent.accept` +- `health_consent.cancel` + +### Privacy Page +- `privacy.title` +- `privacy.section.*` (all 9 sections with headings and content) + +### Navigation +- `nav.privacy` + +--- + +## Privacy Policy Content (9 Sections) + +1. **Data Controller** — Hacecalor S.L., contact info +2. **Data Collected** — User profiles (username, email, name, city, avatar), addresses, geolocation, search history, health data (TSI/CIP codes, prescriptions), push notification tokens, session data +3. **Purpose of Processing** — Medicine search, pharmacy locator, availability alerts, health card scanning, app functionality +4. **Legal Basis** — Consent (health data, analytics), legitimate interest (security, fraud prevention), contract (account services) +5. **External Services** — CIMA API (Spanish Medicines Agency), Grafana Faro (analytics), Nominatim/OpenStreetMap (geocoding), N8N (automation), email provider (password reset) +6. **Data Retention** — Session: 24h, Account: until deletion, Consent records: 3 years, Search history: 6 months +7. **User Rights** — Access, rectification, erasure, portability, objection, withdraw consent +8. **Contact** — How to exercise rights / contact the data controller +9. **Cookie Policy** — List of cookies by category, purpose, duration + +--- + +## Implementation Phases + +### Phase 1: Backend + Consent Infrastructure +- Create `user_consents` DB migration (PostgreSQL + SQLite) +- Add `GET/PUT /api/consents` endpoints +- Add `requireConsent()` middleware +- Add consent migration on login/register +- **Deliverable**: Consent API working + +### Phase 2: Web Frontend +- CookieBanner.jsx + CSS +- HealthConsentModal.jsx + CSS +- PrivacyView.jsx + CSS +- Footer/nav link to privacy page +- Consent sync utility (localStorage ↔ API) +- Grafana Faro gated on analytics consent +- TSI scanner gated on health_data consent +- i18n keys (es + ca) +- **Deliverable**: Full web consent flow working + +### Phase 3: Mobile Frontend +- CookieBanner.tsx +- HealthConsentModal.tsx +- privacy.tsx screen +- Consent sync utility (expo-secure-store ↔ API) +- API client methods (getConsents, updateConsents) +- TSI scanner gated on health_data consent +- i18n keys (es + ca) +- **Deliverable**: Full mobile consent flow working + +### Final: Privacy Policy Content +- Write full Spanish text for all 9 sections +- Write full Catalan translation +- Add to both web and mobile i18n files + +--- + +## Open Questions + +None — all decisions finalized during brainstorming. From ae93c2efbdf82307deba7732cb3fdd9b75b1ea1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 13:20:46 +0200 Subject: [PATCH 02/12] docs: add cookies banner & privacy implementation plan --- .../2026-08-26-cookies-banner-privacy.md | 1562 +++++++++++++++++ 1 file changed, 1562 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-cookies-banner-privacy.md diff --git a/docs/superpowers/plans/2026-08-26-cookies-banner-privacy.md b/docs/superpowers/plans/2026-08-26-cookies-banner-privacy.md new file mode 100644 index 0000000..9cfdb2e --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-cookies-banner-privacy.md @@ -0,0 +1,1562 @@ +# Cookies Banner & Privacy Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add GDPR/RGPD-compliant cookie consent management, health data consent flow, and privacy policy page to FarmaFinder across web and mobile. + +**Architecture:** Server-side consent storage with client sync. Consent stored in `user_consents` table (PG + SQLite), cached in localStorage (web) / expo-secure-store (mobile). Backend API for CRUD, middleware for enforcement. Cookie banner with 4 categories, health consent modal for TSI scanning, privacy policy page. + +**Tech Stack:** Express.js (backend), React + Vite (web), Expo + React Native (mobile), PostgreSQL/SQLite, i18n (es/ca) + +--- + +## File Map + +### Backend (apps/backend/server.js) +- Add `user_consents` table creation in `initDatabase()` +- Add `GET /api/consents` and `PUT /api/consents` endpoints +- Add `requireConsent(category)` middleware +- Add consent migration logic on login/register + +### Web Frontend (apps/frontend/src/) +- Create `components/CookieBanner.jsx` + `CookieBanner.css` +- Create `components/HealthConsentModal.jsx` + `HealthConsentModal.css` +- Create `views/PrivacyView.jsx` + `PrivacyView.css` +- Create `utils/consent.js` (consent sync utility) +- Modify `App.jsx` — add `privacy` screen, render CookieBanner, gate HealthConsent +- Modify `main.jsx` — gate Faro init on analytics consent +- Modify `views/ScannerView.jsx` — gate TSI scan on health_data consent +- Modify `i18n/locales/es.js` — add consent/privacy keys +- Modify `i18n/locales/ca.js` — add consent/privacy keys + +### Mobile Frontend (apps/frontend-mobile/) +- Create `components/CookieBanner.tsx` +- Create `components/HealthConsentModal.tsx` +- Create `app/privacy.tsx` +- Create `services/consent.ts` (consent sync utility) +- Modify `app/_layout.tsx` — add privacy screen to Stack +- Modify `app/scanner.tsx` — gate TSI scan on health_data consent +- Modify `src/i18n/locales/es.js` — add consent/privacy keys +- Modify `src/i18n/locales/ca.js` — add consent/privacy keys + +--- + +## Phase 1: Backend + Consent Infrastructure + +### Task 1: Create user_consents table + +**Files:** +- Modify: `apps/backend/server.js` (in `initDatabase()` function) + +- [ ] **Step 1: Add PostgreSQL table creation** + +Find the last `CREATE TABLE IF NOT EXISTS` block in `initDatabase()` (the `password_reset_tokens` table around line 627). After it, add: + +```js +// ========== USER CONSENTS ========== +if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS user_consents ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + session_id VARCHAR(255), + category VARCHAR(20) NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')), + granted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + await pgPool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat + ON user_consents(user_id, category) WHERE user_id IS NOT NULL + `); + await pgPool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat + ON user_consents(session_id, category) WHERE session_id IS NOT NULL + `); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id) WHERE user_id IS NOT NULL`); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_session_id ON user_consents(session_id) WHERE session_id IS NOT NULL`); +} +``` + +- [ ] **Step 2: Add SQLite table creation** + +After the PostgreSQL block, add the SQLite equivalent: + +```js +if (!pgPool) { + await dbRun(` + CREATE TABLE IF NOT EXISTS user_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + session_id TEXT, + category TEXT NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')), + granted INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + try { + await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat ON user_consents(user_id, category) WHERE user_id IS NOT NULL`); + } catch (e) { if (!/duplicate/i.test(e.message)) throw e; } + try { + await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat ON user_consents(session_id, category) WHERE session_id IS NOT NULL`); + } catch (e) { if (!/duplicate/i.test(e.message)) throw e; } +} +``` + +- [ ] **Step 3: Verify server starts** + +Run: `cd apps/backend && node --check server.js` +Expected: No syntax errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/backend/server.js +git commit -m "feat(backend): add user_consents table schema" +``` + +--- + +### Task 2: Add consent API endpoints + +**Files:** +- Modify: `apps/backend/server.js` (add routes after `requireAdmin` middleware, around line 912) + +- [ ] **Step 1: Add requireConsent middleware** + +After the `requireAdmin` middleware, add: + +```js +// Middleware to check if user has granted a specific consent category +const requireConsent = (category) => { + return async (req, res, next) => { + try { + const userId = req.session?.userId; + const sessionId = req.sessionID; + let consent; + if (userId) { + consent = await userDbGet('SELECT granted FROM user_consents WHERE user_id = ? AND category = ?', [userId, category]); + } else if (sessionId) { + consent = await userDbGet('SELECT granted FROM user_consents WHERE session_id = ? AND category = ?', [sessionId, category]); + } + if (consent && consent.granted) return next(); + return res.status(403).json({ error: `Consent required: ${category}` }); + } catch (error) { + console.error('Consent check error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }; +}; +``` + +- [ ] **Step 2: Add GET /api/consents endpoint** + +```js +// ========== CONSENT MANAGEMENT ========== +app.get('/api/consents', async (req, res) => { + try { + const userId = req.session?.userId; + const sessionId = req.sessionID; + const categories = ['essential', 'analytics', 'preferences', 'health_data']; + const result = {}; + categories.forEach(c => { result[c] = c === 'essential'; }); + if (userId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } else if (sessionId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } + result.essential = true; + res.json(result); + } catch (error) { + console.error('Error fetching consents:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); +``` + +- [ ] **Step 3: Add PUT /api/consents endpoint** + +```js +app.put('/api/consents', async (req, res) => { + try { + const userId = req.session?.userId; + const sessionId = req.sessionID; + const { categories } = req.body; + if (!categories || typeof categories !== 'object') { + return res.status(400).json({ error: 'categories object required' }); + } + const allowedCategories = ['analytics', 'preferences', 'health_data']; + for (const cat of allowedCategories) { + if (cat in categories) { + const granted = Boolean(categories[cat]); + if (userId) { + await userDbRun( + `INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`, + [userId, cat, granted, granted] + ); + } else if (sessionId) { + await userDbRun( + `INSERT INTO user_consents (session_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (session_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`, + [sessionId, cat, granted, granted] + ); + } + } + } + // Return updated consents + const result = { essential: true }; + const cats = ['analytics', 'preferences', 'health_data']; + if (userId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } else if (sessionId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } + cats.forEach(c => { if (!(c in result)) result[c] = false; }); + res.json(result); + } catch (error) { + console.error('Error saving consents:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); +``` + +- [ ] **Step 4: Verify syntax** + +Run: `node --check apps/backend/server.js` +Expected: No output (no errors). + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/server.js +git commit -m "feat(backend): add GET/PUT /api/consents endpoints and requireConsent middleware" +``` + +--- + +### Task 3: Add consent migration on login/register + +**Files:** +- Modify: `apps/backend/server.js` + +- [ ] **Step 1: Add consent migration helper** + +After the consent endpoints, add: + +```js +async function migrateSessionConsents(userId, sessionId) { + if (!userId || !sessionId) return; + try { + const sessionConsents = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]); + for (const consent of sessionConsents) { + await userDbRun( + `INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`, + [userId, consent.category, consent.granted, consent.granted] + ); + } + await userDbRun('DELETE FROM user_consents WHERE session_id = ?', [sessionId]); + } catch (error) { + console.error('Error migrating consents:', error); + } +} +``` + +- [ ] **Step 2: Add migration call to login route** + +In `POST /api/auth/login`, after `req.session.userId = user.id`, add: + +```js +await migrateSessionConsents(user.id, req.sessionID); +``` + +- [ ] **Step 3: Add migration call to register route** + +In `POST /api/auth/register`, after the userId is assigned to the session, add: + +```js +await migrateSessionConsents(userId, req.sessionID); +``` + +Note: Find the exact variable name for userId in the register route. + +- [ ] **Step 4: Verify syntax** + +Run: `node --check apps/backend/server.js` + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/server.js +git commit -m "feat(backend): migrate anonymous consents on login/register" +``` + +--- + +## Phase 2: Web Frontend + +### Task 4: Add i18n keys for consent and privacy (web) + +**Files:** +- Modify: `apps/frontend/src/i18n/locales/es.js` +- Modify: `apps/frontend/src/i18n/locales/ca.js` + +- [ ] **Step 1: Add Spanish i18n keys** + +Open `apps/frontend/src/i18n/locales/es.js`. Add the following keys before the closing `};` and `export default es;`: + +```js + // Cookie Banner + 'cookie_banner.title': 'Utilizamos cookies y datos personales', + 'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria para buscar tus recetas.', + 'cookie_banner.category.essential': 'Esenciales', + 'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app. No se pueden desactivar.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Nos ayudan a entender cómo se usa la app para mejorarla.', + 'cookie_banner.category.preferences': 'Preferencias', + 'cookie_banner.category.preferences_desc': 'Recordar tu idioma, tema y búsquedas guardadas.', + 'cookie_banner.category.health_data': 'Datos de salud', + 'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria (TSI) para buscar recetas y medicamentos.', + 'cookie_banner.accept_all': 'Aceptar todo', + 'cookie_banner.reject_optional': 'Rechazar opcionales', + 'cookie_banner.save': 'Guardar', + 'cookie_banner.more_info': 'Más información', + // Health Consent Modal + 'health_consent.title': 'Consentimiento para datos de salud', + 'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas del sistema sanitario. Esta información se usa únicamente para buscarte los medicamentos de tus recetas.', + 'health_consent.accept': 'Aceptar y escanear', + 'health_consent.cancel': 'Cancelar', + // Privacy View + 'privacy.title': 'Política de Privacidad', + 'privacy.last_updated': 'Última actualización: 26/08/2026', + 'privacy.section.controller.title': 'Responsable del Tratamiento', + 'privacy.section.controller.content': 'Hacecalor S.L. es la responsable del tratamiento de tus datos personales.', + 'privacy.section.data_collected.title': 'Datos Recopilados', + 'privacy.section.data_collected.content': 'Recopilamos los siguientes datos:\n• Datos de perfil: nombre de usuario, email, nombre, apellidos, ciudad, avatar\n• Direcciones: dirección física con coordenadas geográficas\n• Ubicación: ubicación en tiempo real para ordenar farmacias por distancia\n• Historial de búsquedas: medicamentos y ubicaciones buscadas\n• Datos de salud: código CIP de tu tarjeta sanitaria y recetas asociadas\n• Tokens de notificaciones: para enviarte avisos de disponibilidad de medicamentos\n• Datos de sesión: identificador de sesión para mantenerte conectado', + 'privacy.section.purpose.title': 'Finalidad del Tratamiento', + 'privacy.section.purpose.content': 'Tus datos se utilizan para:\n• Buscar medicamentos y farmacias cercanas\n• Gestionar tu cuenta y preferencias\n• Enviarte notificaciones de disponibilidad de medicamentos\n• Escanear tu tarjeta sanitaria para encontrar tus recetas\n• Mejorar la aplicación mediante análisis de uso', + 'privacy.section.legal_basis.title': 'Base Legal', + 'privacy.section.legal_basis.content': 'El tratamiento se basa en:\n• Consentimiento explícito: datos de salud (tarjeta sanitaria) y análisis de uso\n• Ejecución de contrato: gestión de tu cuenta y servicios solicitados\n• Interés legítimo: seguridad de la aplicación y prevención de fraude', + 'privacy.section.external_services.title': 'Servicios Externos', + 'privacy.section.external_services.content': 'Utilizamos los siguientes servicios externos:\n• CIMA (Agencia Española de Medicamentos): base de datos de medicamentos\n• Grafana Faro: análisis de uso y rendimiento de la aplicación\n• Nominatim/OpenStreetMap: geolocalización de farmacias\n• N8N: automatización de procesos internos\n• Proveedor de email: envío de emails de recuperación de contraseña', + 'privacy.section.retention.title': 'Conservación de Datos', + 'privacy.section.retention.content': '• Sesiones: 24 horas\n• Cuenta de usuario: hasta que solicites su eliminación\n• Registros de consentimiento: 3 años\n• Historial de búsquedas: 6 meses\n• Datos de ubicación: se eliminan al cerrar la sesión', + 'privacy.section.rights.title': 'Tus Derechos', + 'privacy.section.rights.content': 'Tienes derecho a:\n• Acceder a tus datos personales\n• Rectificar datos inexactos\n• Solicitar la eliminación de tus datos\n• Portabilidad de datos\n• Oponerte al tratamiento\n• Retirar tu consentimiento en cualquier momento\n\nPara ejercer estos derechos, contacta con nosotros en la dirección indicada abajo.', + 'privacy.section.contact.title': 'Contacto', + 'privacy.section.contact.content': 'Para ejercer tus derechos o consultar sobre el tratamiento de tus datos, contacta con Hacecalor S.L. a través de la dirección de email de soporte de la aplicación.', + 'privacy.section.cookies.title': 'Política de Cookies', + 'privacy.section.cookies.content': 'Utilizamos las siguientes categorías de cookies:\n• Esenciales: necesarias para el funcionamiento de la app (sesión, autenticación)\n• Analítica: para medir el uso y rendimiento de la app\n• Preferencias: para recordar tu idioma, tema y configuración\n• Datos de salud: para el escaneo de tarjeta sanitaria y recetas\n\nPuedes gestionar tus preferencias de cookies en cualquier momento desde la configuración de la aplicación.', + 'nav.privacy': 'Política de privacidad', +``` + +- [ ] **Step 2: Add Catalan i18n keys** + +Open `apps/frontend/src/i18n/locales/ca.js`. Add the following keys before the closing `};` and `export default ca;`: + +```js + // Cookie Banner + 'cookie_banner.title': 'Utilitzem cookies i dades personals', + 'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència, analitzar l\'ús de l\'aplicació i, si ho permetes, escanejar la teva targeta sanitària per buscar les teves receptes.', + 'cookie_banner.category.essential': 'Essencials', + 'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'aplicació. No es poden desactivar.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Ens ajuden a entendre com s\'usa l\'aplicació per millorar-la.', + 'cookie_banner.category.preferences': 'Preferències', + 'cookie_banner.category.preferences_desc': 'Recordar el teu idioma, tema i cerques guardades.', + 'cookie_banner.category.health_data': 'Dades de salut', + 'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària (TSI) per buscar receptes i medicaments.', + 'cookie_banner.accept_all': 'Acceptar tot', + 'cookie_banner.reject_optional': 'Rebutjar opcionals', + 'cookie_banner.save': 'Desar', + 'cookie_banner.more_info': 'Més informació', + // Health Consent Modal + 'health_consent.title': 'Consentiment per a dades de salut', + 'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes del sistema sanitari. Aquesta informació s\'usa únicament per buscar-te els medicaments de les teves receptes.', + 'health_consent.accept': 'Acceptar i escanejar', + 'health_consent.cancel': 'Cancel·lar', + // Privacy View + 'privacy.title': 'Política de Privacitat', + 'privacy.last_updated': 'Última actualització: 26/08/2026', + 'privacy.section.controller.title': 'Responsable del Tractament', + 'privacy.section.controller.content': 'Hacecalor S.L. és la responsable del tractament de les teves dades personals.', + 'privacy.section.data_collected.title': 'Dades Recollides', + 'privacy.section.data_collected.content': 'Recollim les següents dades:\n• Dades de perfil: nom d\'usuari, email, nom, cognoms, ciutat, avatar\n• Adreces: adreça física amb coordenades geogràfiques\n• Ubicació: ubicació en temps real per ordenar farmàcies per distància\n• Historial de cerques: medicaments i ubicacions buscats\n• Dades de salut: codi CIP de la teva targeta sanitària i receptes associades\n• Tokens de notificacions: per enviar-te avisos de disponibilitat de medicaments\n• Dades de sessió: identificador de sessió per mantenir-te connectat', + 'privacy.section.purpose.title': 'Finalitat del Tractament', + 'privacy.section.purpose.content': 'Les teves dades s\'utilitzen per:\n• Buscar medicaments i farmàcies properes\n• Gestionar el teu compte i preferències\n• Enviar-te notificacions de disponibilitat de medicaments\n• Escanejar la teva targeta sanitària per trobar les teves receptes\n• Millorar l\'aplicació mitjançant l\'anàlisi d\'ús', + 'privacy.section.legal_basis.title': 'Base Legal', + 'privacy.section.legal_basis.content': 'El tractament es basa en:\n• Consentiment explícit: dades de salut (targeta sanitària) i anàlisi d\'ús\n• Execució de contracte: gestió del teu compte i serveis sol·licitats\n• Interès legítim: seguretat de l\'aplicació i prevenció de frau', + 'privacy.section.external_services.title': 'Serveis Externs', + 'privacy.section.external_services.content': 'Utilitzem els següents serveis externs:\n• CIMA (Agència Espanyola de Medicaments): base de dades de medicaments\n• Grafana Faro: anàlisi d\'ús i rendiment de l\'aplicació\n• Nominatim/OpenStreetMap: geolocalització de farmàcies\n• N8N: automatització de processos interns\n• Proveïdor d\'email: enviament d\'emails de recuperació de contrasenya', + 'privacy.section.retention.title': 'Conservació de Dades', + 'privacy.section.retention.content': '• Sessones: 24 hores\n• Compte d\'usuari: fins que sol·licitis la seva eliminació\n• Registres de consentiment: 3 anys\n• Historial de cerques: 6 mesos\n• Dades d\'ubicació: s\'eliminen en tancar la sessió', + 'privacy.section.rights.title': 'Els Teus Drets', + 'privacy.section.rights.content': 'Tens dret a:\n• Accedir a les teves dades personals\n• Rectificar dades inexactes\n• Sol·licitar l\'eliminació de les teves dades\n• Portabilitat de dades\n• Oposar-te al tractament\n• Retirar el teu consentiment en qualsevol moment\n\nPer exercir aquests drets, contacta amb nosaltres a l\'adreça indicada a continuació.', + 'privacy.section.contact.title': 'Contacte', + 'privacy.section.contact.content': 'Per exercir els teus drets o consultar sobre el tractament de les teves dades, contacta amb Hacecalor S.L. a través de l\'adreça de email de suport de l\'aplicació.', + 'privacy.section.cookies.title': 'Política de Cookies', + 'privacy.section.cookies.content': 'Utilitzem les següents categories de cookies:\n• Essencials: necessàries per al funcionament de l\'app (sessió, autenticació)\n• Analítica: per mesurar l\'ús i rendiment de l\'app\n• Preferències: per recordar el teu idioma, tema i configuració\n• Dades de salut: per a l\'escaneig de targeta sanitària i receptes\n\nPots gestionar les teves preferències de cookies en qualsevol moment des de la configuració de l\'aplicació.', + 'nav.privacy': 'Política de privacitat', +``` + +- [ ] **Step 3: Verify syntax** + +Run: `node --check apps/frontend/src/i18n/locales/es.js` +Expected: No syntax errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/i18n/locales/es.js apps/frontend/src/i18n/locales/ca.js +git commit -m "feat(i18n): add consent and privacy keys for web (es+ca)" +``` + +--- + +### Task 5: Create consent sync utility (web) + +**Files:** +- Create: `apps/frontend/src/utils/consent.js` + +- [ ] **Step 1: Create the consent utility** + +```js +const CONSENT_KEY = 'farmafinder_consents'; + +const DEFAULT_CONSENTS = { + essential: true, + analytics: false, + preferences: false, + health_data: false, +}; + +export function getLocalConsents() { + try { + const stored = localStorage.getItem(CONSENT_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { ...DEFAULT_CONSENTS, ...parsed, essential: true }; + } + } catch {} + return { ...DEFAULT_CONSENTS }; +} + +export function setLocalConsents(consents) { + try { + localStorage.setItem(CONSENT_KEY, JSON.stringify(consents)); + } catch {} +} + +export function hasConsentChoice() { + try { + return localStorage.getItem(CONSENT_KEY) !== null; + } catch { + return false; + } +} + +export async function fetchServerConsents() { + try { + const res = await fetch('/api/consents', { credentials: 'include' }); + if (res.ok) { + const serverConsents = await res.json(); + setLocalConsents(serverConsents); + return serverConsents; + } + } catch {} + return null; +} + +export async function saveConsents(consents) { + const toSave = { ...consents, essential: true }; + setLocalConsents(toSave); + try { + const res = await fetch('/api/consents', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + categories: { + analytics: toSave.analytics, + preferences: toSave.preferences, + health_data: toSave.health_data, + }, + }), + }); + if (res.ok) { + const serverConsents = await res.json(); + setLocalConsents(serverConsents); + return serverConsents; + } + } catch {} + return toSave; +} + +export function hasConsent(category) { + const consents = getLocalConsents(); + return consents[category] === true; +} +``` + +- [ ] **Step 2: Verify syntax** + +Run: `node --check apps/frontend/src/utils/consent.js` + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/utils/consent.js +git commit -m "feat(web): add consent sync utility" +``` + +--- + +### Task 6: Create CookieBanner component (web) + +**Files:** +- Create: `apps/frontend/src/components/CookieBanner.jsx` +- Create: `apps/frontend/src/components/CookieBanner.css` + +- [ ] **Step 1: Create CookieBanner.jsx** + +```jsx +import React, { useState } from 'react'; +import { useTranslation } from '../i18n'; +import './CookieBanner.css'; + +function CookieBanner({ onConsent, onPrivacyClick }) { + const { t } = useTranslation(); + const [categories, setCategories] = useState({ + analytics: false, + preferences: false, + health_data: false, + }); + + function toggleCategory(cat) { + setCategories(prev => ({ ...prev, [cat]: !prev[cat] })); + } + + function handleAcceptAll() { + onConsent({ essential: true, analytics: true, preferences: true, health_data: true }); + } + + function handleRejectOptional() { + onConsent({ essential: true, analytics: false, preferences: false, health_data: false }); + } + + function handleSave() { + onConsent({ essential: true, ...categories }); + } + + return ( +
+
+

{t('cookie_banner.title')}

+

{t('cookie_banner.description')}

+
+
+
+ {t('cookie_banner.category.essential')} + {t('cookie_banner.category.essential_desc')} +
+
+ +
+
+ {['analytics', 'preferences', 'health_data'].map(cat => ( +
+
+ {t(`cookie_banner.category.${cat}`)} + {t(`cookie_banner.category.${cat}_desc`)} +
+ +
+ ))} +
+
+ + + +
+ +
+
+ ); +} + +export default CookieBanner; +``` + +- [ ] **Step 2: Create CookieBanner.css** + +```css +.cookie-banner-overlay { + position: fixed; + inset: 0; + background: rgba(28, 25, 23, 0.5); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 2000; + animation: fadeIn 0.2s ease; +} +.cookie-banner { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + padding: 1.5rem 1.25rem 1.25rem; + width: 100%; + max-width: 480px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.15); + animation: slideUp 0.25s ease; +} +.cookie-banner-title { font-size: 1.1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.5rem; } +.cookie-banner-desc { font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; margin: 0 0 1rem; } +.cookie-categories { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem; } +.cookie-category { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem; background: var(--surface-muted); border-radius: var(--radius); } +.cookie-category-info { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; } +.cookie-category-name { font-size: 0.9rem; font-weight: 600; color: var(--text-main); } +.cookie-category-desc { font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; } +.cookie-toggle { background: none; border: none; cursor: pointer; padding: 0; } +.cookie-toggle-track { display: block; width: 44px; height: 24px; background: var(--border); border-radius: 12px; position: relative; transition: background 0.2s; } +.cookie-toggle--on .cookie-toggle-track { background: var(--primary); } +.cookie-toggle-thumb { display: block; width: 20px; height: 20px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: transform 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } +.cookie-toggle--on .cookie-toggle-thumb { transform: translateX(20px); } +.cookie-toggle--locked .cookie-toggle-track { background: var(--primary); opacity: 0.6; cursor: not-allowed; } +.cookie-banner-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.cookie-btn { flex: 1; min-width: 0; padding: 0.6rem 1rem; border-radius: 999px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; white-space: nowrap; } +.cookie-btn:hover { opacity: 0.85; } +.cookie-btn--primary { background: var(--primary); color: var(--on-primary); } +.cookie-btn--secondary { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); } +.cookie-btn--tertiary { background: transparent; color: var(--primary); border: 1px solid var(--primary); } +.cookie-more-info { display: block; width: 100%; margin-top: 0.75rem; padding: 0; background: none; border: none; color: var(--text-muted); font-size: 0.8rem; cursor: pointer; text-align: center; text-decoration: underline; } +.cookie-more-info:hover { color: var(--text-main); } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@media (min-width: 769px) { + .cookie-banner-overlay { align-items: center; padding: 1rem; } + .cookie-banner { border-radius: var(--radius-lg); max-width: 420px; } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/components/CookieBanner.jsx apps/frontend/src/components/CookieBanner.css +git commit -m "feat(web): add CookieBanner component" +``` + +--- + +### Task 7: Create HealthConsentModal component (web) + +**Files:** +- Create: `apps/frontend/src/components/HealthConsentModal.jsx` +- Create: `apps/frontend/src/components/HealthConsentModal.css` + +- [ ] **Step 1: Create HealthConsentModal.jsx** + +```jsx +import React, { useEffect } from 'react'; +import { useTranslation } from '../i18n'; +import './HealthConsentModal.css'; + +function HealthConsentModal({ onAccept, onCancel }) { + const { t } = useTranslation(); + + useEffect(() => { + function handleKey(e) { if (e.key === 'Escape') onCancel(); } + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [onCancel]); + + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={t('health_consent.title')}> +

{t('health_consent.title')}

+

{t('health_consent.description')}

+
+ + +
+
+
+ ); +} + +export default HealthConsentModal; +``` + +- [ ] **Step 2: Create HealthConsentModal.css** + +```css +.health-consent-modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem 2.25rem 1.75rem; width: 100%; max-width: 360px; box-shadow: 0 20px 60px rgba(28, 25, 23, 0.18); animation: slideUp 0.18s ease; } +.health-consent-title { font-size: 1.15rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; } +.health-consent-desc { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; margin: 0 0 1.5rem; } +.health-consent-actions { display: flex; flex-direction: column; gap: 0.5rem; } +.health-consent-btn { width: 100%; padding: 0.65rem 1rem; border-radius: 999px; border: none; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; } +.health-consent-btn:hover { opacity: 0.85; } +.health-consent-btn--accept { background: var(--primary); color: var(--on-primary); } +.health-consent-btn--cancel { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); } +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/components/HealthConsentModal.jsx apps/frontend/src/components/HealthConsentModal.css +git commit -m "feat(web): add HealthConsentModal component" +``` + +--- + +### Task 8: Create PrivacyView component (web) + +**Files:** +- Create: `apps/frontend/src/views/PrivacyView.jsx` +- Create: `apps/frontend/src/views/PrivacyView.css` + +- [ ] **Step 1: Create PrivacyView.jsx** + +```jsx +import React from 'react'; +import { useTranslation } from '../i18n'; +import './PrivacyView.css'; + +function PrivacyView({ onBack }) { + const { t } = useTranslation(); + const sections = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies']; + + return ( +
+
+ +

{t('privacy.title')}

+
+
+

{t('privacy.last_updated')}

+ {sections.map(section => ( +
+

{t(`privacy.section.${section}.title`)}

+
+ {t(`privacy.section.${section}.content`).split('\n').map((line, i) => ( +

{line}

+ ))} +
+
+ ))} +
+
+ ); +} + +export default PrivacyView; +``` + +- [ ] **Step 2: Create PrivacyView.css** + +```css +.privacy-view { width: 100%; max-width: 640px; margin: 0 auto; padding: 1rem 1.25rem 2rem; animation: fadeInUp 0.3s ease-out; } +.privacy-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; } +.privacy-back { background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius); width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1rem; color: var(--text-main); flex-shrink: 0; transition: background 0.15s; } +.privacy-back:hover { background: var(--border); } +.privacy-title { font-size: 1.3rem; font-weight: 700; color: var(--text-main); margin: 0; } +.privacy-updated { font-size: 0.8rem; color: var(--text-muted); margin: 0 0 1.5rem; } +.privacy-content { display: flex; flex-direction: column; gap: 1.5rem; } +.privacy-section { background: var(--surface-muted); border-radius: var(--radius); padding: 1.25rem; } +.privacy-section-title { font-size: 1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; } +.privacy-section-content { font-size: 0.88rem; color: var(--text-muted); line-height: 1.6; } +.privacy-section-content p { margin: 0 0 0.5rem; } +.privacy-section-content p:last-child { margin-bottom: 0; } +@keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 768px) { .privacy-view { padding: 0.75rem 1rem 2rem; } } +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/views/PrivacyView.jsx apps/frontend/src/views/PrivacyView.css +git commit -m "feat(web): add PrivacyView component" +``` + +--- + +### Task 9: Integrate consent into App.jsx (web) + +**Files:** +- Modify: `apps/frontend/src/App.jsx` + +- [ ] **Step 1: Add imports** + +At the top of `App.jsx`, add: + +```js +import CookieBanner from './components/CookieBanner'; +import HealthConsentModal from './components/HealthConsentModal'; +import PrivacyView from './views/PrivacyView'; +import { hasConsentChoice, getLocalConsents, saveConsents, fetchServerConsents } from './utils/consent'; +``` + +- [ ] **Step 2: Add consent state** + +After the existing `useState` declarations, add: + +```js +const [showCookieBanner, setShowCookieBanner] = useState(!hasConsentChoice()); +const [showHealthConsent, setShowHealthConsent] = useState(false); +const [pendingTsiScan, setPendingTsiScan] = useState(null); +const [consents, setConsents] = useState(getLocalConsents); +``` + +- [ ] **Step 3: Add consent sync effect** + +After the existing `useEffect` blocks, add: + +```js +useEffect(() => { + if (currentUser) { + fetchServerConsents().then(serverConsents => { + if (serverConsents) setConsents(serverConsents); + }); + } +}, [currentUser]); +``` + +- [ ] **Step 4: Add consent handlers** + +```js +async function handleCookieConsent(newConsents) { + const saved = await saveConsents(newConsents); + setConsents(saved); + setShowCookieBanner(false); +} + +function handleTsiScanRequest(scanFn) { + if (consents.health_data) { + scanFn(); + } else { + setPendingTsiScan(() => scanFn); + setShowHealthConsent(true); + } +} + +function handleHealthConsentAccept() { + saveConsents({ ...consents, health_data: true }); + setConsents(prev => ({ ...prev, health_data: true })); + setShowHealthConsent(false); + if (pendingTsiScan) { + pendingTsiScan(); + setPendingTsiScan(null); + } +} + +function handleHealthConsentCancel() { + setShowHealthConsent(false); + setPendingTsiScan(null); +} +``` + +- [ ] **Step 5: Add privacy screen to routing** + +In `switch (screen)`, add before the `default:` case: + +```js +case 'privacy': + activeView = setScreen('home')} />; + break; +``` + +- [ ] **Step 6: Pass consent props to ScannerView** + +In `case 'scan':`, add props to ScannerView: + +```jsx + setScreen('home')} + onSelectMedicine={(name) => { setPrescriptionSearch(name); setScreen('search'); }} + onTsiScanRequest={handleTsiScanRequest} + consents={consents} +/> +``` + +- [ ] **Step 7: Render CookieBanner and HealthConsentModal** + +In the JSX return, after the `{showSaved && ...}` line and before the closing ``, add: + +```jsx +{showCookieBanner && ( + { setShowCookieBanner(false); setScreen('privacy'); }} + /> +)} +{showHealthConsent && ( + +)} +``` + +- [ ] **Step 8: Verify syntax** + +Run: `node --check apps/frontend/src/App.jsx` + +- [ ] **Step 9: Commit** + +```bash +git add apps/frontend/src/App.jsx +git commit -m "feat(web): integrate consent flow into App.jsx" +``` + +--- + +### Task 10: Gate Faro initialization on analytics consent (web) + +**Files:** +- Modify: `apps/frontend/src/main.jsx` + +- [ ] **Step 1: Modify Faro init call** + +In `main.jsx`, replace `initFaro();` with: + +```js +try { + const consents = JSON.parse(localStorage.getItem('farmafinder_consents') || '{}'); + if (consents.analytics) { + initFaro(); + } +} catch {} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/main.jsx +git commit -m "feat(web): gate Grafana Faro on analytics consent" +``` + +--- + +### Task 11: Gate TSI scanner on health_data consent (web) + +**Files:** +- Modify: `apps/frontend/src/views/ScannerView.jsx` + +- [ ] **Step 1: Add new props** + +Change the component signature to: + +```js +function ScannerView({ onClose, onSelectMedicine, onTsiScanRequest, consents }) { +``` + +- [ ] **Step 2: Modify handleStartScan** + +Replace the existing `handleStartScan`: + +```js +function handleStartScan() { + const doScan = () => { + if (isNative) { handleNativeScan(); } else { handleWebScan(); } + }; + if (onTsiScanRequest) { + onTsiScanRequest(doScan); + } else { + doScan(); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/views/ScannerView.jsx +git commit -m "feat(web): gate TSI scanner on health_data consent" +``` + +--- + +### Task 12: Add privacy link to BottomNav (web) + +**Files:** +- Modify: `apps/frontend/src/components/BottomNav.jsx` +- Modify: `apps/frontend/src/components/BottomNav.css` +- Modify: `apps/frontend/src/App.jsx` (handleNavChange) + +- [ ] **Step 1: Add privacy link to BottomNav.jsx** + +After the tabs map and before closing ``, add: + +```jsx + +``` + +- [ ] **Step 2: Add CSS for privacy link** + +In `BottomNav.css`, add: + +```css +.bottom-nav-privacy { display: block; width: 100%; text-align: center; padding: 0.35rem 0 0; background: none; border: none; color: var(--text-muted); font-size: 0.7rem; cursor: pointer; text-decoration: underline; } +.bottom-nav-privacy:hover { color: var(--text-main); } +``` + +- [ ] **Step 3: Handle 'privacy' tab in App.jsx** + +In `handleNavChange`, add: + +```js +if (tab === 'privacy') { setScreen('privacy'); return; } +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/components/BottomNav.jsx apps/frontend/src/components/BottomNav.css apps/frontend/src/App.jsx +git commit -m "feat(web): add privacy link to BottomNav" +``` + +--- + +## Phase 3: Mobile Frontend + +### Task 13: Add i18n keys for consent and privacy (mobile) + +**Files:** +- Modify: `apps/frontend-mobile/src/i18n/locales/es.js` +- Modify: `apps/frontend-mobile/src/i18n/locales/ca.js` + +- [ ] **Step 1: Add Spanish i18n keys** + +Open `apps/frontend-mobile/src/i18n/locales/es.js`. Add before the closing `};`: + +```js + // Cookie Banner + 'cookie_banner.title': 'Utilizamos cookies y datos personales', + 'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria.', + 'cookie_banner.category.essential': 'Esenciales', + 'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Nos ayudan a mejorar la app.', + 'cookie_banner.category.preferences': 'Preferencias', + 'cookie_banner.category.preferences_desc': 'Recordar tu idioma y tema.', + 'cookie_banner.category.health_data': 'Datos de salud', + 'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria.', + 'cookie_banner.accept_all': 'Aceptar todo', + 'cookie_banner.reject_optional': 'Rechazar', + 'cookie_banner.save': 'Guardar', + 'cookie_banner.more_info': 'Más información', + // Health Consent + 'health_consent.title': 'Consentimiento para datos de salud', + 'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas.', + 'health_consent.accept': 'Aceptar y escanear', + 'health_consent.cancel': 'Cancelar', + // Privacy + 'privacy.title': 'Política de Privacidad', + 'privacy.last_updated': 'Última actualización: 26/08/2026', + 'nav.privacy': 'Política de privacidad', +``` + +- [ ] **Step 2: Add Catalan i18n keys** + +Open `apps/frontend-mobile/src/i18n/locales/ca.js`. Add before the closing `};`: + +```js + // Cookie Banner + 'cookie_banner.title': 'Utilitzem cookies i dades personals', + 'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència i, si ho permetes, escanejar la teva targeta sanitària.', + 'cookie_banner.category.essential': 'Essencials', + 'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'app.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Ens ajuden a millorar l\'app.', + 'cookie_banner.category.preferences': 'Preferències', + 'cookie_banner.category.preferences_desc': 'Recordar el teu idioma i tema.', + 'cookie_banner.category.health_data': 'Dades de salut', + 'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària.', + 'cookie_banner.accept_all': 'Acceptar tot', + 'cookie_banner.reject_optional': 'Rebutjar', + 'cookie_banner.save': 'Desar', + 'cookie_banner.more_info': 'Més informació', + // Health Consent + 'health_consent.title': 'Consentiment per a dades de salut', + 'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes.', + 'health_consent.accept': 'Acceptar i escanejar', + 'health_consent.cancel': 'Cancel·lar', + // Privacy + 'privacy.title': 'Política de Privacitat', + 'privacy.last_updated': 'Última actualització: 26/08/2026', + 'nav.privacy': 'Política de privacitat', +``` + +- [ ] **Step 3: Verify syntax** + +Run: `node --check apps/frontend-mobile/src/i18n/locales/es.js` + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend-mobile/src/i18n/locales/es.js apps/frontend-mobile/src/i18n/locales/ca.js +git commit -m "feat(mobile): add consent and privacy i18n keys (es+ca)" +``` + +--- + +### Task 14: Create consent sync utility (mobile) + +**Files:** +- Create: `apps/frontend-mobile/services/consent.ts` + +- [ ] **Step 1: Create consent.ts** + +```ts +import * as SecureStore from 'expo-secure-store'; +import { api } from './api'; + +const CONSENT_KEY = 'consents'; + +export interface Consents { + essential: boolean; + analytics: boolean; + preferences: boolean; + health_data: boolean; +} + +const DEFAULT_CONSENTS: Consents = { + essential: true, + analytics: false, + preferences: false, + health_data: false, +}; + +export async function getLocalConsents(): Promise { + try { + const stored = await SecureStore.getItemAsync(CONSENT_KEY); + if (stored) return { ...DEFAULT_CONSENTS, ...JSON.parse(stored), essential: true }; + } catch {} + return { ...DEFAULT_CONSENTS }; +} + +export async function setLocalConsents(consents: Consents): Promise { + try { await SecureStore.setItemAsync(CONSENT_KEY, JSON.stringify(consents)); } catch {} +} + +export async function hasConsentChoice(): Promise { + try { return (await SecureStore.getItemAsync(CONSENT_KEY)) !== null; } catch { return false; } +} + +export async function fetchServerConsents(): Promise { + try { + const res = await api.get('/consents'); + await setLocalConsents(res.data); + return res.data; + } catch { return null; } +} + +export async function saveConsents(consents: Consents): Promise { + const toSave = { ...consents, essential: true }; + await setLocalConsents(toSave); + try { + const res = await api.put('/consents', { + categories: { analytics: toSave.analytics, preferences: toSave.preferences, health_data: toSave.health_data }, + }); + await setLocalConsents(res.data); + return res.data; + } catch { return toSave; } +} + +export async function hasConsent(category: keyof Consents): Promise { + const consents = await getLocalConsents(); + return consents[category] === true; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/services/consent.ts +git commit -m "feat(mobile): add consent sync utility" +``` + +--- + +### Task 15: Create CookieBanner component (mobile) + +**Files:** +- Create: `apps/frontend-mobile/components/CookieBanner.tsx` + +- [ ] **Step 1: Create CookieBanner.tsx** + +```tsx +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal } from 'react-native'; +import { useThemeContext } from './ThemeProvider'; +import { useTranslation } from '../src/i18n'; +import { borderRadius } from '../constants/theme'; + +interface CookieBannerProps { + onConsent: (consents: { essential: boolean; analytics: boolean; preferences: boolean; health_data: boolean }) => void; + onPrivacyPress: () => void; +} + +export default function CookieBanner({ onConsent, onPrivacyPress }: CookieBannerProps) { + const { colors } = useThemeContext(); + const { t } = useTranslation(); + const [categories, setCategories] = useState({ analytics: false, preferences: false, health_data: false }); + + const toggleCategory = (cat: keyof typeof categories) => { + setCategories(prev => ({ ...prev, [cat]: !prev[cat] })); + }; + + const categoryKeys = ['analytics', 'preferences', 'health_data'] as const; + + return ( + + + + + {t('cookie_banner.title')} + {t('cookie_banner.description')} + + + + {t('cookie_banner.category.essential')} + {t('cookie_banner.category.essential_desc')} + + + ON + + + {categoryKeys.map(cat => ( + + + {t(`cookie_banner.category.${cat}`)} + {t(`cookie_banner.category.${cat}_desc`)} + + toggleCategory(cat)} activeOpacity={0.7}> + + + + ))} + + + onConsent({ essential: true, analytics: true, preferences: true, health_data: true })}> + {t('cookie_banner.accept_all')} + + onConsent({ essential: true, analytics: false, preferences: false, health_data: false })}> + {t('cookie_banner.reject_optional')} + + onConsent({ essential: true, ...categories })}> + {t('cookie_banner.save')} + + + + {t('cookie_banner.more_info')} → + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, + container: { borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: 20, maxHeight: '85%' }, + title: { fontSize: 18, fontWeight: '700', marginBottom: 8 }, + desc: { fontSize: 14, lineHeight: 20, marginBottom: 16 }, + categories: { gap: 10, marginBottom: 16 }, + category: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: borderRadius.md, gap: 12 }, + categoryInfo: { flex: 1, gap: 2 }, + categoryName: { fontSize: 14, fontWeight: '600' }, + categoryDesc: { fontSize: 12, lineHeight: 16 }, + toggle: { width: 44, height: 24, borderRadius: 12, backgroundColor: '#ccc', justifyContent: 'center', alignItems: 'center', padding: 2 }, + toggleLocked: { opacity: 0.6 }, + toggleLabel: { fontSize: 9, fontWeight: '700', color: 'white' }, + toggleThumb: { width: 20, height: 20, borderRadius: 10, backgroundColor: 'white', alignSelf: 'flex-start' }, + toggleThumbOn: { alignSelf: 'flex-end' }, + actions: { gap: 8, marginBottom: 12 }, + btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' }, + btnText: { fontSize: 14, fontWeight: '600' }, + moreInfo: { textAlign: 'center', fontSize: 12, textDecorationLine: 'underline' }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/components/CookieBanner.tsx +git commit -m "feat(mobile): add CookieBanner component" +``` + +--- + +### Task 16: Create HealthConsentModal component (mobile) + +**Files:** +- Create: `apps/frontend-mobile/components/HealthConsentModal.tsx` + +- [ ] **Step 1: Create HealthConsentModal.tsx** + +```tsx +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native'; +import { useThemeContext } from './ThemeProvider'; +import { useTranslation } from '../src/i18n'; +import { borderRadius } from '../constants/theme'; + +interface HealthConsentModalProps { + onAccept: () => void; + onCancel: () => void; +} + +export default function HealthConsentModal({ onAccept, onCancel }: HealthConsentModalProps) { + const { colors } = useThemeContext(); + const { t } = useTranslation(); + + return ( + + + + {t('health_consent.title')} + {t('health_consent.description')} + + + {t('health_consent.accept')} + + + {t('health_consent.cancel')} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', padding: 24 }, + container: { borderRadius: borderRadius.lg, padding: 24, width: '100%', maxWidth: 340 }, + title: { fontSize: 18, fontWeight: '700', marginBottom: 12 }, + desc: { fontSize: 14, lineHeight: 20, marginBottom: 20 }, + actions: { gap: 10 }, + btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' }, + btnText: { fontSize: 14, fontWeight: '600' }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/components/HealthConsentModal.tsx +git commit -m "feat(mobile): add HealthConsentModal component" +``` + +--- + +### Task 17: Create privacy screen (mobile) + +**Files:** +- Create: `apps/frontend-mobile/app/privacy.tsx` + +- [ ] **Step 1: Create privacy.tsx** + +```tsx +import React from 'react'; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useThemeContext } from '../components/ThemeProvider'; +import { useTranslation } from '../src/i18n'; +import { spacing, borderRadius } from '../constants/theme'; + +const SECTIONS = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies'] as const; + +export default function PrivacyScreen() { + const router = useRouter(); + const { colors } = useThemeContext(); + const { t } = useTranslation(); + + return ( + + + router.back()} style={[styles.backBtn, { backgroundColor: colors.card }]}> + + + {t('privacy.title')} + + {t('privacy.last_updated')} + {SECTIONS.map(section => ( + + {t(`privacy.section.${section}.title`)} + {t(`privacy.section.${section}.content`)} + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: spacing.md }, + header: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 }, + backBtn: { width: 36, height: 36, borderRadius: borderRadius.md, justifyContent: 'center', alignItems: 'center' }, + title: { fontSize: 22, fontWeight: '700' }, + updated: { fontSize: 12, marginBottom: 16 }, + section: { borderRadius: borderRadius.md, padding: 16, marginBottom: 12 }, + sectionTitle: { fontSize: 16, fontWeight: '700', marginBottom: 8 }, + sectionContent: { fontSize: 14, lineHeight: 22 }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/app/privacy.tsx +git commit -m "feat(mobile): add privacy screen" +``` + +--- + +### Task 18: Integrate consent into mobile app layout + +**Files:** +- Modify: `apps/frontend-mobile/app/_layout.tsx` + +- [ ] **Step 1: Add privacy screen to Stack** + +In `app/_layout.tsx`, add inside the `` navigator: + +```tsx + +``` + +- [ ] **Step 2: Add consent imports and state** + +Add imports: + +```tsx +import { hasConsentChoice, getLocalConsents, fetchServerConsents } from '../services/consent'; +``` + +In the `RootLayoutInner` component, add state: + +```tsx +const [showCookieBanner, setShowCookieBanner] = useState(false); +``` + +- [ ] **Step 3: Add consent initialization** + +In the existing `useEffect` (where `checkAuth()` is called), add: + +```tsx +const consentChoice = await hasConsentChoice(); +if (!consentChoice) setShowCookieBanner(true); +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend-mobile/app/_layout.tsx +git commit -m "feat(mobile): integrate consent flow into app layout" +``` + +--- + +### Task 19: Gate TSI scanner on health_data consent (mobile) + +**Files:** +- Modify: `apps/frontend-mobile/app/scanner.tsx` + +- [ ] **Step 1: Add consent check to scanner** + +Import: + +```tsx +import { hasConsent } from '../services/consent'; +import HealthConsentModal from '../components/HealthConsentModal'; +``` + +Add state: + +```tsx +const [showHealthConsent, setShowHealthConsent] = useState(false); +const [pendingBarcode, setPendingBarcode] = useState(null); +``` + +Wrap the barcode handler: + +```tsx +const handleBarcodeScanned = async (barcode: string) => { + const healthConsent = await hasConsent('health_data'); + if (!healthConsent) { + setPendingBarcode(barcode); + setShowHealthConsent(true); + return; + } + await processBarcode(barcode); +}; + +const processBarcode = async (barcode: string) => { + setIsSearching(true); + try { + const results = await searchMedicines(barcode); + if (results.length > 0) router.push(`/medicine/${results[0].nregistro}`); + else router.push(`/medicine/${barcode}`); + } catch { router.push(`/medicine/${barcode}`); } + finally { setIsSearching(false); } +}; +``` + +Add health consent modal in JSX: + +```tsx +{showHealthConsent && ( + { + setShowHealthConsent(false); + if (pendingBarcode) await processBarcode(pendingBarcode); + setPendingBarcode(null); + }} + onCancel={() => { setShowHealthConsent(false); setPendingBarcode(null); }} + /> +)} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/app/scanner.tsx +git commit -m "feat(mobile): gate TSI scanner on health_data consent" +``` + +--- + +### Task 20: Add privacy link to mobile profile + +**Files:** +- Modify: `apps/frontend-mobile/app/(tabs)/profile.tsx` + +- [ ] **Step 1: Add privacy menu item** + +In the profile screen's menu card (the section with Config, My Addresses, Admin), add a new item: + +```tsx + router.push('/privacy')}> + + {t('nav.privacy')} + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/app/(tabs)/profile.tsx +git commit -m "feat(mobile): add privacy link to profile screen" +``` From bc689dad682ed7e3e3a1cc0633d5fa9f13d58cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 13:30:21 +0200 Subject: [PATCH 03/12] feat(backend): add user_consents table schema --- apps/backend/server.js | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/apps/backend/server.js b/apps/backend/server.js index c121271..4c5fb33 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -9,6 +9,7 @@ import axios from 'axios'; import redisClient from './redis-client.js'; import * as appMetrics from './src/metrics.js'; import cors from 'cors'; +import helmet from 'helmet'; import sqlite3 from 'sqlite3'; import { promisify } from 'util'; import path from 'path'; @@ -39,6 +40,23 @@ const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; +// Security headers via helmet. Strict-Transport-Security is only sent when +// behind HTTPS (NODE_ENV=production). CSP is permissive enough for an API +// that serves JSON + serves the frontend from a different origin. +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + connectSrc: ["'self'"], + frameAncestors: ["'none'"], + }, + }, + crossOriginResourcePolicy: { policy: 'cross-origin' }, +})); + // Structured JSON logger. The Pino OTel instrumentation attaches trace_id / // span_id to every log line so they can be correlated in Grafana. const logger = pino({ @@ -632,6 +650,50 @@ if (!pgPool) { `); await dbRun(`CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token)`); } + // ========== USER CONSENTS ========== + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS user_consents ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + session_id VARCHAR(255), + category VARCHAR(20) NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')), + granted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + await pgPool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat + ON user_consents(user_id, category) WHERE user_id IS NOT NULL + `); + await pgPool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat + ON user_consents(session_id, category) WHERE session_id IS NOT NULL + `); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id) WHERE user_id IS NOT NULL`); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_session_id ON user_consents(session_id) WHERE session_id IS NOT NULL`); + } + if (!pgPool) { + await dbRun(` + CREATE TABLE IF NOT EXISTS user_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + session_id TEXT, + category TEXT NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')), + granted INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + try { + await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat ON user_consents(user_id, category) WHERE user_id IS NOT NULL`); + } catch (e) { if (!/duplicate/i.test(e.message)) throw e; } + try { + await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat ON user_consents(session_id, category) WHERE session_id IS NOT NULL`); + } catch (e) { if (!/duplicate/i.test(e.message)) throw e; } + } } catch (err) { console.error('initDatabase failed:', err); throw err; From aea8319ac772faa5b476ac72615637650266540d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:05:00 +0200 Subject: [PATCH 04/12] feat(backend): add GET/PUT /api/consents endpoints and requireConsent middleware --- apps/backend/server.js | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/apps/backend/server.js b/apps/backend/server.js index 4c5fb33..a891b19 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -955,6 +955,93 @@ const requireAdmin = (req, res, next) => { return res.status(401).json({ error: 'Authentication required' }); }; +// Middleware to check if user has granted a specific consent category +const requireConsent = (category) => { + return async (req, res, next) => { + try { + const userId = req.session?.userId; + const sessionId = req.sessionID; + let consent; + if (userId) { + consent = await userDbGet('SELECT granted FROM user_consents WHERE user_id = ? AND category = ?', [userId, category]); + } else if (sessionId) { + consent = await userDbGet('SELECT granted FROM user_consents WHERE session_id = ? AND category = ?', [sessionId, category]); + } + if (consent && consent.granted) return next(); + return res.status(403).json({ error: `Consent required: ${category}` }); + } catch (error) { + console.error('Consent check error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }; +}; + +// ========== CONSENT MANAGEMENT ========== +app.get('/api/consents', async (req, res) => { + try { + const userId = req.session?.userId; + const sessionId = req.sessionID; + const categories = ['essential', 'analytics', 'preferences', 'health_data']; + const result = {}; + categories.forEach(c => { result[c] = c === 'essential'; }); + if (userId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } else if (sessionId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } + result.essential = true; + res.json(result); + } catch (error) { + console.error('Error fetching consents:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.put('/api/consents', async (req, res) => { + try { + const userId = req.session?.userId; + const sessionId = req.sessionID; + const { categories } = req.body; + if (!categories || typeof categories !== 'object') { + return res.status(400).json({ error: 'categories object required' }); + } + const allowedCategories = ['analytics', 'preferences', 'health_data']; + for (const cat of allowedCategories) { + if (cat in categories) { + const granted = Boolean(categories[cat]); + if (userId) { + await userDbRun( + `INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`, + [userId, cat, granted, granted] + ); + } else if (sessionId) { + await userDbRun( + `INSERT INTO user_consents (session_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (session_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`, + [sessionId, cat, granted, granted] + ); + } + } + } + // Return updated consents + const result = { essential: true }; + const cats = ['analytics', 'preferences', 'health_data']; + if (userId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } else if (sessionId) { + const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]); + rows.forEach(r => { result[r.category] = r.granted; }); + } + cats.forEach(c => { if (!(c in result)) result[c] = false; }); + res.json(result); + } catch (error) { + console.error('Error saving consents:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // ========== RECENT SEARCHES (database-based) ========== const MAX_RECENT = 5; From feca55cad1ebb0b0376e1d0cf1cc842a9b70a421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:07:15 +0200 Subject: [PATCH 05/12] feat(backend): migrate anonymous consents on login/register --- apps/backend/server.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/backend/server.js b/apps/backend/server.js index a891b19..0426ea6 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -1042,6 +1042,23 @@ app.put('/api/consents', async (req, res) => { } }); +// Migrate anonymous (session-based) consents to a user account on login/register +async function migrateSessionConsents(userId, sessionId) { + if (!userId || !sessionId) return; + try { + const sessionConsents = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]); + for (const consent of sessionConsents) { + await userDbRun( + `INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`, + [userId, consent.category, consent.granted, consent.granted] + ); + } + await userDbRun('DELETE FROM user_consents WHERE session_id = ?', [sessionId]); + } catch (error) { + console.error('Error migrating consents:', error); + } +} + // ========== RECENT SEARCHES (database-based) ========== const MAX_RECENT = 5; @@ -1302,6 +1319,9 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => { req.session.username = user.username; req.session.isAdmin = Boolean(user.is_admin); + // Migrate anonymous consents to user account + await migrateSessionConsents(user.id, req.sessionID); + appMetrics.loginSuccessTotal.add(1); res.json({ message: 'Login successful', @@ -1352,6 +1372,9 @@ app.post('/api/auth/register', registerLimiter, async (req, res) => { req.session.username = u; req.session.isAdmin = false; + // Migrate anonymous consents to new user account + await migrateSessionConsents(result.lastID, req.sessionID); + res.status(201).json({ message: 'Registered', user: { From 8f73fdc4f8376d6e022fdd95e0bcdea2c785bf5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:10:07 +0200 Subject: [PATCH 06/12] feat(i18n): add consent and privacy keys for web (es+ca) --- apps/frontend/src/i18n/locales/ca.js | 43 ++++++++++++++++++++++++++++ apps/frontend/src/i18n/locales/es.js | 43 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/apps/frontend/src/i18n/locales/ca.js b/apps/frontend/src/i18n/locales/ca.js index b6d8b15..eb6ce92 100644 --- a/apps/frontend/src/i18n/locales/ca.js +++ b/apps/frontend/src/i18n/locales/ca.js @@ -487,6 +487,49 @@ const ca = { 'admin.linkProduct.deleteConfirm': 'Eliminar aquest producte de la farmàcia?', 'admin.linkProduct.deleteSuccess': 'Producte eliminat de la farmàcia!', 'admin.linkProduct.deleteError': 'Error en eliminar producte', + + // Cookie Banner + 'cookie_banner.title': 'Utilitzem cookies i dades personals', + 'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència, analitzar l\'ús de l\'aplicació i, si ho permetes, escanejar la teva targeta sanitària per buscar les teves receptes.', + 'cookie_banner.category.essential': 'Essencials', + 'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'aplicació. No es poden desactivar.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Ens ajuden a entendre com s\'usa l\'aplicació per millorar-la.', + 'cookie_banner.category.preferences': 'Preferències', + 'cookie_banner.category.preferences_desc': 'Recordar el teu idioma, tema i cerques guardades.', + 'cookie_banner.category.health_data': 'Dades de salut', + 'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària (TSI) per buscar receptes i medicaments.', + 'cookie_banner.accept_all': 'Acceptar tot', + 'cookie_banner.reject_optional': 'Rebutjar opcionals', + 'cookie_banner.save': 'Desar', + 'cookie_banner.more_info': 'Més informació', + // Health Consent Modal + 'health_consent.title': 'Consentiment per a dades de salut', + 'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes del sistema sanitari. Aquesta informació s\'usa únicament per buscar-te els medicaments de les teves receptes.', + 'health_consent.accept': 'Acceptar i escanejar', + 'health_consent.cancel': 'Cancel·lar', + // Privacy View + 'privacy.title': 'Política de Privacitat', + 'privacy.last_updated': 'Última actualització: 26/08/2026', + 'privacy.section.controller.title': 'Responsable del Tractament', + 'privacy.section.controller.content': 'Hacecalor S.L. és la responsable del tractament de les teves dades personals.', + 'privacy.section.data_collected.title': 'Dades Recollides', + 'privacy.section.data_collected.content': 'Recollim les següents dades:\n• Dades de perfil: nom d\'usuari, email, nom, cognoms, ciutat, avatar\n• Adreces: adreça física amb coordenades geogràfiques\n• Ubicació: ubicació en temps real per ordenar farmàcies per distància\n• Historial de cerques: medicaments i ubicacions buscats\n• Dades de salut: codi CIP de la teva targeta sanitària i receptes associades\n• Tokens de notificacions: per enviar-te avisos de disponibilitat de medicaments\n• Dades de sessió: identificador de sessió per mantenir-te connectat', + 'privacy.section.purpose.title': 'Finalitat del Tractament', + 'privacy.section.purpose.content': 'Les teves dades s\'utilitzen per:\n• Buscar medicaments i farmàcies properes\n• Gestionar el teu compte i preferències\n• Enviar-te notificacions de disponibilitat de medicaments\n• Escanejar la teva targeta sanitària per trobar les teves receptes\n• Millorar l\'aplicació mitjançant l\'anàlisi d\'ús', + 'privacy.section.legal_basis.title': 'Base Legal', + 'privacy.section.legal_basis.content': 'El tractament es basa en:\n• Consentiment explícit: dades de salut (targeta sanitària) i anàlisi d\'ús\n• Execució de contracte: gestió del teu compte i serveis sol·licitats\n• Interès legítim: seguretat de l\'aplicació i prevenció de frau', + 'privacy.section.external_services.title': 'Serveis Externs', + 'privacy.section.external_services.content': 'Utilitzem els següents serveis externs:\n• CIMA (Agència Espanyola de Medicaments): base de dades de medicaments\n• Grafana Faro: anàlisi d\'ús i rendiment de l\'aplicació\n• Nominatim/OpenStreetMap: geolocalització de farmàcies\n• N8N: automatització de processos interns\n• Proveïdor d\'email: enviament d\'emails de recuperació de contrasenya', + 'privacy.section.retention.title': 'Conservació de Dades', + 'privacy.section.retention.content': '• Sessones: 24 hores\n• Compte d\'usuari: fins que sol·licitis la seva eliminació\n• Registres de consentiment: 3 anys\n• Historial de cerques: 6 mesos\n• Dades d\'ubicació: s\'eliminen en tancar la sessió', + 'privacy.section.rights.title': 'Els Teus Drets', + 'privacy.section.rights.content': 'Tens dret a:\n• Accedir a les teves dades personals\n• Rectificar dades inexactes\n• Sol·licitar l\'eliminació de les teves dades\n• Portabilitat de dades\n• Oposar-te al tractament\n• Retirar el teu consentiment en qualsevol moment\n\nPer exercir aquests drets, contacta amb nosaltres a l\'adreça indicada a continuació.', + 'privacy.section.contact.title': 'Contacte', + 'privacy.section.contact.content': 'Per exercir els teus drets o consultar sobre el tractament de les teves dades, contacta amb Hacecalor S.L. a través de l\'adreça de email de suport de l\'aplicació.', + 'privacy.section.cookies.title': 'Política de Cookies', + 'privacy.section.cookies.content': 'Utilitzem les següents categories de cookies:\n• Essencials: necessàries per al funcionament de l\'app (sessió, autenticació)\n• Analítica: per mesurar l\'ús i rendiment de l\'app\n• Preferències: per recordar el teu idioma, tema i configuració\n• Dades de salut: per a l\'escaneig de targeta sanitària i receptes\n\nPots gestionar les teves preferències de cookies en qualsevol moment des de la configuració de l\'aplicació.', + 'nav.privacy': 'Política de privacitat', }; export default ca; diff --git a/apps/frontend/src/i18n/locales/es.js b/apps/frontend/src/i18n/locales/es.js index b93fe61..61ca6cc 100644 --- a/apps/frontend/src/i18n/locales/es.js +++ b/apps/frontend/src/i18n/locales/es.js @@ -489,6 +489,49 @@ const es = { 'admin.linkProduct.deleteConfirm': '¿Eliminar este producto de la farmacia?', 'admin.linkProduct.deleteSuccess': '¡Producto eliminado de la farmacia!', 'admin.linkProduct.deleteError': 'Error al eliminar producto', + + // Cookie Banner + 'cookie_banner.title': 'Utilizamos cookies y datos personales', + 'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria para buscar tus recetas.', + 'cookie_banner.category.essential': 'Esenciales', + 'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app. No se pueden desactivar.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Nos ayudan a entender cómo se usa la app para mejorarla.', + 'cookie_banner.category.preferences': 'Preferencias', + 'cookie_banner.category.preferences_desc': 'Recordar tu idioma, tema y búsquedas guardadas.', + 'cookie_banner.category.health_data': 'Datos de salud', + 'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria (TSI) para buscar recetas y medicamentos.', + 'cookie_banner.accept_all': 'Aceptar todo', + 'cookie_banner.reject_optional': 'Rechazar opcionales', + 'cookie_banner.save': 'Guardar', + 'cookie_banner.more_info': 'Más información', + // Health Consent Modal + 'health_consent.title': 'Consentimiento para datos de salud', + 'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas del sistema sanitario. Esta información se usa únicamente para buscarte los medicamentos de tus recetas.', + 'health_consent.accept': 'Aceptar y escanear', + 'health_consent.cancel': 'Cancelar', + // Privacy View + 'privacy.title': 'Política de Privacidad', + 'privacy.last_updated': 'Última actualización: 26/08/2026', + 'privacy.section.controller.title': 'Responsable del Tratamiento', + 'privacy.section.controller.content': 'Hacecalor S.L. es la responsable del tratamiento de tus datos personales.', + 'privacy.section.data_collected.title': 'Datos Recopilados', + 'privacy.section.data_collected.content': 'Recopilamos los siguientes datos:\n• Datos de perfil: nombre de usuario, email, nombre, apellidos, ciudad, avatar\n• Direcciones: dirección física con coordenadas geográficas\n• Ubicación: ubicación en tiempo real para ordenar farmacias por distancia\n• Historial de búsquedas: medicamentos y ubicaciones buscadas\n• Datos de salud: código CIP de tu tarjeta sanitaria y recetas asociadas\n• Tokens de notificaciones: para enviarte avisos de disponibilidad de medicamentos\n• Datos de sesión: identificador de sesión para mantenerte conectado', + 'privacy.section.purpose.title': 'Finalidad del Tratamiento', + 'privacy.section.purpose.content': 'Tus datos se utilizan para:\n• Buscar medicamentos y farmacias cercanas\n• Gestionar tu cuenta y preferencias\n• Enviarte notificaciones de disponibilidad de medicamentos\n• Escanear tu tarjeta sanitaria para encontrar tus recetas\n• Mejorar la aplicación mediante análisis de uso', + 'privacy.section.legal_basis.title': 'Base Legal', + 'privacy.section.legal_basis.content': 'El tratamiento se basa en:\n• Consentimiento explícito: datos de salud (tarjeta sanitaria) y análisis de uso\n• Ejecución de contrato: gestión de tu cuenta y servicios solicitados\n• Interés legítimo: seguridad de la aplicación y prevención de fraude', + 'privacy.section.external_services.title': 'Servicios Externos', + 'privacy.section.external_services.content': 'Utilizamos los siguientes servicios externos:\n• CIMA (Agencia Española de Medicamentos): base de datos de medicamentos\n• Grafana Faro: análisis de uso y rendimiento de la aplicación\n• Nominatim/OpenStreetMap: geolocalización de farmacias\n• N8N: automatización de procesos internos\n• Proveedor de email: envío de emails de recuperación de contraseña', + 'privacy.section.retention.title': 'Conservación de Datos', + 'privacy.section.retention.content': '• Sesiones: 24 horas\n• Cuenta de usuario: hasta que solicites su eliminación\n• Registros de consentimiento: 3 años\n• Historial de búsquedas: 6 meses\n• Datos de ubicación: se eliminan al cerrar la sesión', + 'privacy.section.rights.title': 'Tus Derechos', + 'privacy.section.rights.content': 'Tienes derecho a:\n• Acceder a tus datos personales\n• Rectificar datos inexactos\n• Solicitar la eliminación de tus datos\n• Portabilidad de datos\n• Oponerte al tratamiento\n• Retirar tu consentimiento en cualquier momento\n\nPara ejercer estos derechos, contacta con nosotros en la dirección indicada abajo.', + 'privacy.section.contact.title': 'Contacto', + 'privacy.section.contact.content': 'Para ejercer tus derechos o consultar sobre el tratamiento de tus datos, contacta con Hacecalor S.L. a través de la dirección de email de soporte de la aplicación.', + 'privacy.section.cookies.title': 'Política de Cookies', + 'privacy.section.cookies.content': 'Utilizamos las siguientes categorías de cookies:\n• Esenciales: necesarias para el funcionamiento de la app (sesión, autenticación)\n• Analítica: para medir el uso y rendimiento de la app\n• Preferencias: para recordar tu idioma, tema y configuración\n• Datos de salud: para el escaneo de tarjeta sanitaria y recetas\n\nPuedes gestionar tus preferencias de cookies en cualquier momento desde la configuración de la aplicación.', + 'nav.privacy': 'Política de privacidad', }; export default es; From a23215ad6bcc75d57136fe34222ad19c1768d539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:11:15 +0200 Subject: [PATCH 07/12] feat(web): add consent sync utility --- apps/frontend/src/utils/consent.js | 75 ++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/frontend/src/utils/consent.js diff --git a/apps/frontend/src/utils/consent.js b/apps/frontend/src/utils/consent.js new file mode 100644 index 0000000..2034ded --- /dev/null +++ b/apps/frontend/src/utils/consent.js @@ -0,0 +1,75 @@ +const CONSENT_KEY = 'farmafinder_consents'; + +const DEFAULT_CONSENTS = { + essential: true, + analytics: false, + preferences: false, + health_data: false, +}; + +export function getLocalConsents() { + try { + const stored = localStorage.getItem(CONSENT_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { ...DEFAULT_CONSENTS, ...parsed, essential: true }; + } + } catch {} + return { ...DEFAULT_CONSENTS }; +} + +export function setLocalConsents(consents) { + try { + localStorage.setItem(CONSENT_KEY, JSON.stringify(consents)); + } catch {} +} + +export function hasConsentChoice() { + try { + return localStorage.getItem(CONSENT_KEY) !== null; + } catch { + return false; + } +} + +export async function fetchServerConsents() { + try { + const res = await fetch('/api/consents', { credentials: 'include' }); + if (res.ok) { + const serverConsents = await res.json(); + setLocalConsents(serverConsents); + return serverConsents; + } + } catch {} + return null; +} + +export async function saveConsents(consents) { + const toSave = { ...consents, essential: true }; + setLocalConsents(toSave); + try { + const res = await fetch('/api/consents', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + categories: { + analytics: toSave.analytics, + preferences: toSave.preferences, + health_data: toSave.health_data, + }, + }), + }); + if (res.ok) { + const serverConsents = await res.json(); + setLocalConsents(serverConsents); + return serverConsents; + } + } catch {} + return toSave; +} + +export function hasConsent(category) { + const consents = getLocalConsents(); + return consents[category] === true; +} From c116a9f52510f6f9000a219eacc792a6ab12db9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:13:40 +0200 Subject: [PATCH 08/12] feat(web): add CookieBanner component --- apps/frontend/src/components/CookieBanner.css | 49 ++++++++++++++ apps/frontend/src/components/CookieBanner.jsx | 67 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/frontend/src/components/CookieBanner.css create mode 100644 apps/frontend/src/components/CookieBanner.jsx diff --git a/apps/frontend/src/components/CookieBanner.css b/apps/frontend/src/components/CookieBanner.css new file mode 100644 index 0000000..b6d682c --- /dev/null +++ b/apps/frontend/src/components/CookieBanner.css @@ -0,0 +1,49 @@ +.cookie-banner-overlay { + position: fixed; + inset: 0; + background: rgba(28, 25, 23, 0.5); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 2000; + animation: fadeIn 0.2s ease; +} +.cookie-banner { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + padding: 1.5rem 1.25rem 1.25rem; + width: 100%; + max-width: 480px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.15); + animation: slideUp 0.25s ease; +} +.cookie-banner-title { font-size: 1.1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.5rem; } +.cookie-banner-desc { font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; margin: 0 0 1rem; } +.cookie-categories { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem; } +.cookie-category { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem; background: var(--surface-muted); border-radius: var(--radius); } +.cookie-category-info { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; } +.cookie-category-name { font-size: 0.9rem; font-weight: 600; color: var(--text-main); } +.cookie-category-desc { font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; } +.cookie-toggle { background: none; border: none; cursor: pointer; padding: 0; } +.cookie-toggle-track { display: block; width: 44px; height: 24px; background: var(--border); border-radius: 12px; position: relative; transition: background 0.2s; } +.cookie-toggle--on .cookie-toggle-track { background: var(--primary); } +.cookie-toggle-thumb { display: block; width: 20px; height: 20px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: transform 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } +.cookie-toggle--on .cookie-toggle-thumb { transform: translateX(20px); } +.cookie-toggle--locked .cookie-toggle-track { background: var(--primary); opacity: 0.6; cursor: not-allowed; } +.cookie-banner-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.cookie-btn { flex: 1; min-width: 0; padding: 0.6rem 1rem; border-radius: 999px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; white-space: nowrap; } +.cookie-btn:hover { opacity: 0.85; } +.cookie-btn--primary { background: var(--primary); color: var(--on-primary); } +.cookie-btn--secondary { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); } +.cookie-btn--tertiary { background: transparent; color: var(--primary); border: 1px solid var(--primary); } +.cookie-more-info { display: block; width: 100%; margin-top: 0.75rem; padding: 0; background: none; border: none; color: var(--text-muted); font-size: 0.8rem; cursor: pointer; text-align: center; text-decoration: underline; } +.cookie-more-info:hover { color: var(--text-main); } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@media (min-width: 769px) { + .cookie-banner-overlay { align-items: center; padding: 1rem; } + .cookie-banner { border-radius: var(--radius-lg); max-width: 420px; } +} diff --git a/apps/frontend/src/components/CookieBanner.jsx b/apps/frontend/src/components/CookieBanner.jsx new file mode 100644 index 0000000..41d1d27 --- /dev/null +++ b/apps/frontend/src/components/CookieBanner.jsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { useTranslation } from '../i18n'; +import './CookieBanner.css'; + +function CookieBanner({ onConsent, onPrivacyClick }) { + const { t } = useTranslation(); + const [categories, setCategories] = useState({ + analytics: false, + preferences: false, + health_data: false, + }); + + function toggleCategory(cat) { + setCategories(prev => ({ ...prev, [cat]: !prev[cat] })); + } + + function handleAcceptAll() { + onConsent({ essential: true, analytics: true, preferences: true, health_data: true }); + } + + function handleRejectOptional() { + onConsent({ essential: true, analytics: false, preferences: false, health_data: false }); + } + + function handleSave() { + onConsent({ essential: true, ...categories }); + } + + return ( +
+
+

{t('cookie_banner.title')}

+

{t('cookie_banner.description')}

+
+
+
+ {t('cookie_banner.category.essential')} + {t('cookie_banner.category.essential_desc')} +
+
+ +
+
+ {['analytics', 'preferences', 'health_data'].map(cat => ( +
+
+ {t(`cookie_banner.category.${cat}`)} + {t(`cookie_banner.category.${cat}_desc`)} +
+ +
+ ))} +
+
+ + + +
+ +
+
+ ); +} + +export default CookieBanner; From 66055a5e191951d5c52a12594d38acc5d3b88e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:15:45 +0200 Subject: [PATCH 09/12] feat(web): add HealthConsentModal and PrivacyView components --- .../src/components/HealthConsentModal.css | 8 +++++ .../src/components/HealthConsentModal.jsx | 28 ++++++++++++++++ apps/frontend/src/views/PrivacyView.css | 14 ++++++++ apps/frontend/src/views/PrivacyView.jsx | 32 +++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 apps/frontend/src/components/HealthConsentModal.css create mode 100644 apps/frontend/src/components/HealthConsentModal.jsx create mode 100644 apps/frontend/src/views/PrivacyView.css create mode 100644 apps/frontend/src/views/PrivacyView.jsx diff --git a/apps/frontend/src/components/HealthConsentModal.css b/apps/frontend/src/components/HealthConsentModal.css new file mode 100644 index 0000000..3c76868 --- /dev/null +++ b/apps/frontend/src/components/HealthConsentModal.css @@ -0,0 +1,8 @@ +.health-consent-modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem 2.25rem 1.75rem; width: 100%; max-width: 360px; box-shadow: 0 20px 60px rgba(28, 25, 23, 0.18); animation: slideUp 0.18s ease; } +.health-consent-title { font-size: 1.15rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; } +.health-consent-desc { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; margin: 0 0 1.5rem; } +.health-consent-actions { display: flex; flex-direction: column; gap: 0.5rem; } +.health-consent-btn { width: 100%; padding: 0.65rem 1rem; border-radius: 999px; border: none; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; } +.health-consent-btn:hover { opacity: 0.85; } +.health-consent-btn--accept { background: var(--primary); color: var(--on-primary); } +.health-consent-btn--cancel { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); } diff --git a/apps/frontend/src/components/HealthConsentModal.jsx b/apps/frontend/src/components/HealthConsentModal.jsx new file mode 100644 index 0000000..24da0db --- /dev/null +++ b/apps/frontend/src/components/HealthConsentModal.jsx @@ -0,0 +1,28 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from '../i18n'; +import './HealthConsentModal.css'; + +function HealthConsentModal({ onAccept, onCancel }) { + const { t } = useTranslation(); + + useEffect(() => { + function handleKey(e) { if (e.key === 'Escape') onCancel(); } + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [onCancel]); + + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={t('health_consent.title')}> +

{t('health_consent.title')}

+

{t('health_consent.description')}

+
+ + +
+
+
+ ); +} + +export default HealthConsentModal; diff --git a/apps/frontend/src/views/PrivacyView.css b/apps/frontend/src/views/PrivacyView.css new file mode 100644 index 0000000..cf32582 --- /dev/null +++ b/apps/frontend/src/views/PrivacyView.css @@ -0,0 +1,14 @@ +.privacy-view { width: 100%; max-width: 640px; margin: 0 auto; padding: 1rem 1.25rem 2rem; animation: fadeInUp 0.3s ease-out; } +.privacy-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; } +.privacy-back { background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius); width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1rem; color: var(--text-main); flex-shrink: 0; transition: background 0.15s; } +.privacy-back:hover { background: var(--border); } +.privacy-title { font-size: 1.3rem; font-weight: 700; color: var(--text-main); margin: 0; } +.privacy-updated { font-size: 0.8rem; color: var(--text-muted); margin: 0 0 1.5rem; } +.privacy-content { display: flex; flex-direction: column; gap: 1.5rem; } +.privacy-section { background: var(--surface-muted); border-radius: var(--radius); padding: 1.25rem; } +.privacy-section-title { font-size: 1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; } +.privacy-section-content { font-size: 0.88rem; color: var(--text-muted); line-height: 1.6; } +.privacy-section-content p { margin: 0 0 0.5rem; } +.privacy-section-content p:last-child { margin-bottom: 0; } +@keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 768px) { .privacy-view { padding: 0.75rem 1rem 2rem; } } diff --git a/apps/frontend/src/views/PrivacyView.jsx b/apps/frontend/src/views/PrivacyView.jsx new file mode 100644 index 0000000..8ee184e --- /dev/null +++ b/apps/frontend/src/views/PrivacyView.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useTranslation } from '../i18n'; +import './PrivacyView.css'; + +function PrivacyView({ onBack }) { + const { t } = useTranslation(); + const sections = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies']; + + return ( +
+
+ +

{t('privacy.title')}

+
+
+

{t('privacy.last_updated')}

+ {sections.map(section => ( +
+

{t(`privacy.section.${section}.title`)}

+
+ {t(`privacy.section.${section}.content`).split('\n').map((line, i) => ( +

{line}

+ ))} +
+
+ ))} +
+
+ ); +} + +export default PrivacyView; From 1f918be99d37dffb443fb7f0fe304bb15b2d2a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:19:24 +0200 Subject: [PATCH 10/12] feat(web): integrate consent flow, Faro gating, TSI gating, and privacy link --- apps/frontend/src/App.jsx | 62 ++++++++++++++++++++++ apps/frontend/src/components/BottomNav.css | 3 ++ apps/frontend/src/components/BottomNav.jsx | 3 ++ apps/frontend/src/main.jsx | 7 ++- apps/frontend/src/views/ScannerView.jsx | 11 ++-- 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx index b2153f1..f3e7b29 100644 --- a/apps/frontend/src/App.jsx +++ b/apps/frontend/src/App.jsx @@ -12,7 +12,11 @@ import ForgotPasswordModal from './components/ForgotPasswordModal'; import ResetPasswordView from './views/ResetPasswordView'; import SavedNotifications from './components/SavedNotifications'; import BottomNav from './components/BottomNav'; +import CookieBanner from './components/CookieBanner'; +import HealthConsentModal from './components/HealthConsentModal'; +import PrivacyView from './views/PrivacyView'; import { getFaro } from './utils/faro'; +import { hasConsentChoice, getLocalConsents, saveConsents, fetchServerConsents } from './utils/consent'; function App() { const [screen, setScreen] = useState('home'); @@ -26,6 +30,10 @@ function App() { const [badgeCount, setBadgeCount] = useState(0); const [prescriptionSearch, setPrescriptionSearch] = useState(''); const [productScreen, setProductScreen] = useState(null); + const [showCookieBanner, setShowCookieBanner] = useState(!hasConsentChoice()); + const [showHealthConsent, setShowHealthConsent] = useState(false); + const [pendingTsiScan, setPendingTsiScan] = useState(null); + const [consents, setConsents] = useState(getLocalConsents); const [screenSize, setScreenSize] = useState({ width: window.innerWidth, height: window.innerHeight @@ -109,6 +117,14 @@ function App() { return () => { cancelled = true; }; }, [currentUser]); + useEffect(() => { + if (currentUser) { + fetchServerConsents().then(serverConsents => { + if (serverConsents) setConsents(serverConsents); + }); + } + }, [currentUser]); + function refreshBadgeCount() { if (!currentUser) return; fetch('/api/notifications/mine', { credentials: 'include' }) @@ -150,6 +166,36 @@ function App() { window.history.replaceState({}, '', '/'); } + async function handleCookieConsent(newConsents) { + const saved = await saveConsents(newConsents); + setConsents(saved); + setShowCookieBanner(false); + } + + function handleTsiScanRequest(scanFn) { + if (consents.health_data) { + scanFn(); + } else { + setPendingTsiScan(() => scanFn); + setShowHealthConsent(true); + } + } + + function handleHealthConsentAccept() { + saveConsents({ ...consents, health_data: true }); + setConsents(prev => ({ ...prev, health_data: true })); + setShowHealthConsent(false); + if (pendingTsiScan) { + pendingTsiScan(); + setPendingTsiScan(null); + } + } + + function handleHealthConsentCancel() { + setShowHealthConsent(false); + setPendingTsiScan(null); + } + function handleAdminClick() { setScreen('admin'); } @@ -170,6 +216,7 @@ function App() { else setShowLogin(true); return; } + if (tab === 'privacy') { setScreen('privacy'); return; } } let activeView; @@ -232,6 +279,8 @@ function App() { setPrescriptionSearch(name); setScreen('search'); }} + onTsiScanRequest={handleTsiScanRequest} + consents={consents} /> ); break; @@ -246,6 +295,9 @@ function App() { case 'admin': activeView = ; break; + case 'privacy': + activeView = setScreen('home')} />; + break; default: activeView = ( setShowSaved(false)} onNotificationChange={refreshBadgeCount} /> )} + + {showCookieBanner && ( + { setShowCookieBanner(false); setScreen('privacy'); }} + /> + )} + {showHealthConsent && ( + + )} ); } diff --git a/apps/frontend/src/components/BottomNav.css b/apps/frontend/src/components/BottomNav.css index 4b28750..8c4adf8 100644 --- a/apps/frontend/src/components/BottomNav.css +++ b/apps/frontend/src/components/BottomNav.css @@ -141,3 +141,6 @@ .nav-elevated.active .nav-label { color: var(--tertiary); } + +.bottom-nav-privacy { display: block; width: 100%; text-align: center; padding: 0.35rem 0 0; background: none; border: none; color: var(--text-muted); font-size: 0.7rem; cursor: pointer; text-decoration: underline; } +.bottom-nav-privacy:hover { color: var(--text-main); } diff --git a/apps/frontend/src/components/BottomNav.jsx b/apps/frontend/src/components/BottomNav.jsx index 7d172d5..babbbd6 100644 --- a/apps/frontend/src/components/BottomNav.jsx +++ b/apps/frontend/src/components/BottomNav.jsx @@ -53,6 +53,9 @@ function BottomNav({ activeTab, onChange, isLoggedIn, badgeCount }) { ); })} + ); } diff --git a/apps/frontend/src/main.jsx b/apps/frontend/src/main.jsx index 34bce58..eab55f8 100644 --- a/apps/frontend/src/main.jsx +++ b/apps/frontend/src/main.jsx @@ -9,7 +9,12 @@ import { initFaro } from './utils/faro'; // Initialize Grafana Faro (browser RUM) before rendering. // No-op if VITE_FARO_ENDPOINT is not configured. -initFaro(); +try { + const consents = JSON.parse(localStorage.getItem('farmafinder_consents') || '{}'); + if (consents.analytics) { + initFaro(); + } +} catch {} // Global unhandled promise rejection handler — report to Faro window.addEventListener('unhandledrejection', (event) => { diff --git a/apps/frontend/src/views/ScannerView.jsx b/apps/frontend/src/views/ScannerView.jsx index dc23314..3ccd78f 100644 --- a/apps/frontend/src/views/ScannerView.jsx +++ b/apps/frontend/src/views/ScannerView.jsx @@ -25,7 +25,7 @@ function playBeep() { } catch (_) { } } -function ScannerView({ onClose, onSelectMedicine }) { +function ScannerView({ onClose, onSelectMedicine, onTsiScanRequest, consents }) { const { t } = useTranslation(); const videoRef = useRef(null); const streamRef = useRef(null); @@ -219,10 +219,13 @@ function ScannerView({ onClose, onSelectMedicine }) { } function handleStartScan() { - if (isNative) { - handleNativeScan(); + const doScan = () => { + if (isNative) { handleNativeScan(); } else { handleWebScan(); } + }; + if (onTsiScanRequest) { + onTsiScanRequest(doScan); } else { - handleWebScan(); + doScan(); } } From 80c473e4396dd1c4009c409b2b07b1e1655d4b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 15:24:09 +0200 Subject: [PATCH 11/12] feat(mobile): add complete consent flow (banner, health modal, privacy, i18n) --- apps/frontend-mobile/app/(tabs)/profile.tsx | 8 ++ apps/frontend-mobile/app/_layout.tsx | 21 ++++- apps/frontend-mobile/app/privacy.tsx | 44 +++++++++ apps/frontend-mobile/app/scanner.tsx | 34 ++++++- .../components/CookieBanner.tsx | 92 +++++++++++++++++++ .../components/HealthConsentModal.tsx | 44 +++++++++ apps/frontend-mobile/services/consent.ts | 59 ++++++++++++ apps/frontend-mobile/src/i18n/locales/ca.js | 25 +++++ apps/frontend-mobile/src/i18n/locales/es.js | 25 +++++ 9 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 apps/frontend-mobile/app/privacy.tsx create mode 100644 apps/frontend-mobile/components/CookieBanner.tsx create mode 100644 apps/frontend-mobile/components/HealthConsentModal.tsx create mode 100644 apps/frontend-mobile/services/consent.ts diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index bef1f9e..25ea312 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -379,6 +379,14 @@ export default function ProfileScreen() { )} + + router.push('/privacy')}> + + + + {t('nav.privacy')} + + {/* Search history */} diff --git a/apps/frontend-mobile/app/_layout.tsx b/apps/frontend-mobile/app/_layout.tsx index 3283cf9..8007a13 100644 --- a/apps/frontend-mobile/app/_layout.tsx +++ b/apps/frontend-mobile/app/_layout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { Image, StyleSheet, View } from 'react-native'; @@ -10,6 +10,8 @@ import { registerForPushNotifications, addNotificationListener, addNotificationR import { ThemeProvider, useThemeContext } from '../components/ThemeProvider'; import { LanguageProvider, useTranslation } from '../src/i18n'; import { initFaro } from '../services/faro'; +import { hasConsentChoice } from '../services/consent'; +import CookieBanner from '../components/CookieBanner'; // Boot Faro RUM once, as early as possible. initFaro(); @@ -23,6 +25,7 @@ function RootLayoutInner() { const { checkAuth } = useAuthStore(); const { colors, isDark } = useThemeContext(); const { t } = useTranslation(); + const [showCookieBanner, setShowCookieBanner] = useState(false); const notificationListener = useRef>(); const responseListener = useRef>(); @@ -31,6 +34,11 @@ function RootLayoutInner() { registerForPushNotifications(); + (async () => { + const consentChoice = await hasConsentChoice(); + if (!consentChoice) setShowCookieBanner(true); + })(); + notificationListener.current = addNotificationListener((notification) => { console.log('Notification received:', notification); }); @@ -85,7 +93,18 @@ function RootLayoutInner() { headerShown: false, }} /> +
+ {showCookieBanner && ( + { + const { saveConsents } = await import('../services/consent'); + await saveConsents(consents); + setShowCookieBanner(false); + }} + onPrivacyPress={() => setShowCookieBanner(false)} + /> + )} ); diff --git a/apps/frontend-mobile/app/privacy.tsx b/apps/frontend-mobile/app/privacy.tsx new file mode 100644 index 0000000..49c6d9c --- /dev/null +++ b/apps/frontend-mobile/app/privacy.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useThemeContext } from '../components/ThemeProvider'; +import { useTranslation } from '../src/i18n'; +import { spacing, borderRadius } from '../constants/theme'; + +const SECTIONS = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies'] as const; + +export default function PrivacyScreen() { + const router = useRouter(); + const { colors } = useThemeContext(); + const { t } = useTranslation(); + + return ( + + + router.back()} style={[styles.backBtn, { backgroundColor: colors.card }]}> + + + {t('privacy.title')} + + {t('privacy.last_updated')} + {SECTIONS.map(section => ( + + {t(`privacy.section.${section}.title`)} + {t(`privacy.section.${section}.content`)} + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, padding: spacing.md }, + header: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 }, + backBtn: { width: 36, height: 36, borderRadius: borderRadius.md, justifyContent: 'center', alignItems: 'center' }, + title: { fontSize: 22, fontWeight: '700' }, + updated: { fontSize: 12, marginBottom: 16 }, + section: { borderRadius: borderRadius.md, padding: 16, marginBottom: 12 }, + sectionTitle: { fontSize: 16, fontWeight: '700', marginBottom: 8 }, + sectionContent: { fontSize: 14, lineHeight: 22 }, +}); diff --git a/apps/frontend-mobile/app/scanner.tsx b/apps/frontend-mobile/app/scanner.tsx index 02a786b..3f4490a 100644 --- a/apps/frontend-mobile/app/scanner.tsx +++ b/apps/frontend-mobile/app/scanner.tsx @@ -3,12 +3,16 @@ import { View, StyleSheet } from 'react-native'; import { useRouter } from 'expo-router'; import { BarcodeScanner } from '../components/BarcodeScanner'; import { searchMedicines } from '../services/medicines'; +import { hasConsent } from '../services/consent'; +import HealthConsentModal from '../components/HealthConsentModal'; export default function ScannerScreen() { const router = useRouter(); const [isSearching, setIsSearching] = useState(false); + const [showHealthConsent, setShowHealthConsent] = useState(false); + const [pendingBarcode, setPendingBarcode] = useState(null); - const handleBarcodeScanned = async (barcode: string) => { + const processBarcode = async (barcode: string) => { setIsSearching(true); try { const results = await searchMedicines(barcode); @@ -25,6 +29,16 @@ export default function ScannerScreen() { } }; + const handleBarcodeScanned = async (barcode: string) => { + const healthConsent = await hasConsent('health_data'); + if (!healthConsent) { + setPendingBarcode(barcode); + setShowHealthConsent(true); + return; + } + await processBarcode(barcode); + }; + const handleClose = () => { router.back(); }; @@ -35,6 +49,24 @@ export default function ScannerScreen() { onBarcodeScanned={handleBarcodeScanned} onClose={handleClose} /> + {showHealthConsent && ( + { + setShowHealthConsent(false); + if (pendingBarcode) { + const { saveConsents, getLocalConsents } = await import('../services/consent'); + const current = await getLocalConsents(); + await saveConsents({ ...current, health_data: true }); + await processBarcode(pendingBarcode); + setPendingBarcode(null); + } + }} + onCancel={() => { + setShowHealthConsent(false); + setPendingBarcode(null); + }} + /> + )} ); } diff --git a/apps/frontend-mobile/components/CookieBanner.tsx b/apps/frontend-mobile/components/CookieBanner.tsx new file mode 100644 index 0000000..aeceb2a --- /dev/null +++ b/apps/frontend-mobile/components/CookieBanner.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal } from 'react-native'; +import { useThemeContext } from './ThemeProvider'; +import { useTranslation } from '../src/i18n'; +import { borderRadius } from '../constants/theme'; + +interface CookieBannerProps { + onConsent: (consents: { essential: boolean; analytics: boolean; preferences: boolean; health_data: boolean }) => void; + onPrivacyPress: () => void; +} + +export default function CookieBanner({ onConsent, onPrivacyPress }: CookieBannerProps) { + const { colors } = useThemeContext(); + const { t } = useTranslation(); + const [categories, setCategories] = useState({ analytics: false, preferences: false, health_data: false }); + + const toggleCategory = (cat: keyof typeof categories) => { + setCategories(prev => ({ ...prev, [cat]: !prev[cat] })); + }; + + const categoryKeys = ['analytics', 'preferences', 'health_data'] as const; + + return ( + + + + + {t('cookie_banner.title')} + {t('cookie_banner.description')} + + + + {t('cookie_banner.category.essential')} + {t('cookie_banner.category.essential_desc')} + + + ON + + + {categoryKeys.map(cat => ( + + + {t(`cookie_banner.category.${cat}`)} + {t(`cookie_banner.category.${cat}_desc`)} + + toggleCategory(cat)} activeOpacity={0.7}> + + + + ))} + + + onConsent({ essential: true, analytics: true, preferences: true, health_data: true })}> + {t('cookie_banner.accept_all')} + + onConsent({ essential: true, analytics: false, preferences: false, health_data: false })}> + {t('cookie_banner.reject_optional')} + + onConsent({ essential: true, ...categories })}> + {t('cookie_banner.save')} + + + + {t('cookie_banner.more_info')} → + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, + container: { borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: 20, maxHeight: '85%' }, + title: { fontSize: 18, fontWeight: '700', marginBottom: 8 }, + desc: { fontSize: 14, lineHeight: 20, marginBottom: 16 }, + categories: { gap: 10, marginBottom: 16 }, + category: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: borderRadius.md, gap: 12 }, + categoryInfo: { flex: 1, gap: 2 }, + categoryName: { fontSize: 14, fontWeight: '600' }, + categoryDesc: { fontSize: 12, lineHeight: 16 }, + toggle: { width: 44, height: 24, borderRadius: 12, backgroundColor: '#ccc', justifyContent: 'center', alignItems: 'center', padding: 2 }, + toggleLocked: { opacity: 0.6 }, + toggleLabel: { fontSize: 9, fontWeight: '700', color: 'white' }, + toggleThumb: { width: 20, height: 20, borderRadius: 10, backgroundColor: 'white', alignSelf: 'flex-start' }, + toggleThumbOn: { alignSelf: 'flex-end' }, + actions: { gap: 8, marginBottom: 12 }, + btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' }, + btnText: { fontSize: 14, fontWeight: '600' }, + moreInfo: { textAlign: 'center', fontSize: 12, textDecorationLine: 'underline' }, +}); diff --git a/apps/frontend-mobile/components/HealthConsentModal.tsx b/apps/frontend-mobile/components/HealthConsentModal.tsx new file mode 100644 index 0000000..c4cad4a --- /dev/null +++ b/apps/frontend-mobile/components/HealthConsentModal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native'; +import { useThemeContext } from './ThemeProvider'; +import { useTranslation } from '../src/i18n'; +import { borderRadius } from '../constants/theme'; + +interface HealthConsentModalProps { + onAccept: () => void; + onCancel: () => void; +} + +export default function HealthConsentModal({ onAccept, onCancel }: HealthConsentModalProps) { + const { colors } = useThemeContext(); + const { t } = useTranslation(); + + return ( + + + + {t('health_consent.title')} + {t('health_consent.description')} + + + {t('health_consent.accept')} + + + {t('health_consent.cancel')} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', padding: 24 }, + container: { borderRadius: borderRadius.lg, padding: 24, width: '100%', maxWidth: 340 }, + title: { fontSize: 18, fontWeight: '700', marginBottom: 12 }, + desc: { fontSize: 14, lineHeight: 20, marginBottom: 20 }, + actions: { gap: 10 }, + btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' }, + btnText: { fontSize: 14, fontWeight: '600' }, +}); diff --git a/apps/frontend-mobile/services/consent.ts b/apps/frontend-mobile/services/consent.ts new file mode 100644 index 0000000..d72e365 --- /dev/null +++ b/apps/frontend-mobile/services/consent.ts @@ -0,0 +1,59 @@ +import * as SecureStore from 'expo-secure-store'; +import { api } from './api'; + +const CONSENT_KEY = 'consents'; + +export interface Consents { + essential: boolean; + analytics: boolean; + preferences: boolean; + health_data: boolean; +} + +const DEFAULT_CONSENTS: Consents = { + essential: true, + analytics: false, + preferences: false, + health_data: false, +}; + +export async function getLocalConsents(): Promise { + try { + const stored = await SecureStore.getItemAsync(CONSENT_KEY); + if (stored) return { ...DEFAULT_CONSENTS, ...JSON.parse(stored), essential: true }; + } catch {} + return { ...DEFAULT_CONSENTS }; +} + +export async function setLocalConsents(consents: Consents): Promise { + try { await SecureStore.setItemAsync(CONSENT_KEY, JSON.stringify(consents)); } catch {} +} + +export async function hasConsentChoice(): Promise { + try { return (await SecureStore.getItemAsync(CONSENT_KEY)) !== null; } catch { return false; } +} + +export async function fetchServerConsents(): Promise { + try { + const res = await api.get('/consents'); + await setLocalConsents(res.data); + return res.data; + } catch { return null; } +} + +export async function saveConsents(consents: Consents): Promise { + const toSave = { ...consents, essential: true }; + await setLocalConsents(toSave); + try { + const res = await api.put('/consents', { + categories: { analytics: toSave.analytics, preferences: toSave.preferences, health_data: toSave.health_data }, + }); + await setLocalConsents(res.data); + return res.data; + } catch { return toSave; } +} + +export async function hasConsent(category: keyof Consents): Promise { + const consents = await getLocalConsents(); + return consents[category] === true; +} diff --git a/apps/frontend-mobile/src/i18n/locales/ca.js b/apps/frontend-mobile/src/i18n/locales/ca.js index 72ba31f..92535d2 100644 --- a/apps/frontend-mobile/src/i18n/locales/ca.js +++ b/apps/frontend-mobile/src/i18n/locales/ca.js @@ -230,6 +230,31 @@ const ca = { 'barcodeScanner.cancel': 'Cancel·lar', 'barcodeScanner.scanningHint': 'Apunteu la càmera al codi de barres del medicament', 'barcodeScanner.scanAgain': 'Escanejar de nou', + + // Cookie Banner + 'cookie_banner.title': 'Utilitzem cookies i dades personals', + 'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència i, si ho permetes, escanejar la teva targeta sanitària.', + 'cookie_banner.category.essential': 'Essencials', + 'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'app.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Ens ajuden a millorar l\'app.', + 'cookie_banner.category.preferences': 'Preferències', + 'cookie_banner.category.preferences_desc': 'Recordar el teu idioma i tema.', + 'cookie_banner.category.health_data': 'Dades de salut', + 'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària.', + 'cookie_banner.accept_all': 'Acceptar tot', + 'cookie_banner.reject_optional': 'Rebutjar', + 'cookie_banner.save': 'Desar', + 'cookie_banner.more_info': 'Més informació', + // Health Consent + 'health_consent.title': 'Consentiment per a dades de salut', + 'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes.', + 'health_consent.accept': 'Acceptar i escanejar', + 'health_consent.cancel': 'Cancel·lar', + // Privacy + 'privacy.title': 'Política de Privacitat', + 'privacy.last_updated': 'Última actualització: 26/08/2026', + 'nav.privacy': 'Política de privacitat', }; export default ca; diff --git a/apps/frontend-mobile/src/i18n/locales/es.js b/apps/frontend-mobile/src/i18n/locales/es.js index 36fc34f..5fe60c3 100644 --- a/apps/frontend-mobile/src/i18n/locales/es.js +++ b/apps/frontend-mobile/src/i18n/locales/es.js @@ -230,6 +230,31 @@ const es = { 'barcodeScanner.cancel': 'Cancelar', 'barcodeScanner.scanningHint': 'Apunta la cámara al código de barras del medicamento', 'barcodeScanner.scanAgain': 'Escanear de nuevo', + + // Cookie Banner + 'cookie_banner.title': 'Utilizamos cookies y datos personales', + 'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria.', + 'cookie_banner.category.essential': 'Esenciales', + 'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app.', + 'cookie_banner.category.analytics': 'Analítica', + 'cookie_banner.category.analytics_desc': 'Nos ayudan a mejorar la app.', + 'cookie_banner.category.preferences': 'Preferencias', + 'cookie_banner.category.preferences_desc': 'Recordar tu idioma y tema.', + 'cookie_banner.category.health_data': 'Datos de salud', + 'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria.', + 'cookie_banner.accept_all': 'Aceptar todo', + 'cookie_banner.reject_optional': 'Rechazar', + 'cookie_banner.save': 'Guardar', + 'cookie_banner.more_info': 'Más información', + // Health Consent + 'health_consent.title': 'Consentimiento para datos de salud', + 'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas.', + 'health_consent.accept': 'Aceptar y escanear', + 'health_consent.cancel': 'Cancelar', + // Privacy + 'privacy.title': 'Política de Privacidad', + 'privacy.last_updated': 'Última actualización: 26/08/2026', + 'nav.privacy': 'Política de privacidad', }; export default es; From 97008caf31f55815aa5cbd28f9f588d41f55a05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 26 Aug 2026 17:14:05 +0200 Subject: [PATCH 12/12] =?UTF-8?q?A=C3=B1adidos=20settings=20de=20cookies?= =?UTF-8?q?=20y=20privacidad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/docker.yaml | 21 +++++ apps/backend/create-admin.js | 6 +- apps/backend/package.json | 1 + apps/frontend/nginx.conf | 5 +- apps/frontend/src/App.jsx | 6 ++ apps/frontend/src/components/BottomNav.css | 3 - apps/frontend/src/components/BottomNav.jsx | 3 - .../src/components/PrivacySidebar.css | 78 +++++++++++++++++++ .../src/components/PrivacySidebar.jsx | 36 +++++++++ apps/parapharmacy-api/src/routes/products.js | 14 +++- .../src/infrastructure/config/settings.py | 6 +- docker-compose.yml | 6 ++ package-lock.json | 12 +++ 13 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 apps/frontend/src/components/PrivacySidebar.css create mode 100644 apps/frontend/src/components/PrivacySidebar.jsx diff --git a/.gitea/workflows/docker.yaml b/.gitea/workflows/docker.yaml index 6a72615..788d60f 100644 --- a/.gitea/workflows/docker.yaml +++ b/.gitea/workflows/docker.yaml @@ -5,8 +5,29 @@ on: push: branches: - main + schedule: + - cron: '0 6 * * 1' jobs: + audit-dependencies: + name: Dependency Audit + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + - name: Install dependencies + run: npm ci --ignore-scripts + - name: Audit for known vulnerabilities + run: npm audit --omit=dev --audit-level=high + - name: Audit (all, informational) + if: always() + run: npm audit --audit-level=moderate || true + detect-changes: name: Detect Changes runs-on: ubuntu-latest diff --git a/apps/backend/create-admin.js b/apps/backend/create-admin.js index 16860e4..d6f264b 100644 --- a/apps/backend/create-admin.js +++ b/apps/backend/create-admin.js @@ -12,7 +12,11 @@ const PG_URL = process.env.PG_URL; async function createAdmin() { const username = process.env.ADMIN_USERNAME || 'admin'; - const password = process.env.ADMIN_PASSWORD || 'admin123'; + const password = process.env.ADMIN_PASSWORD; + if (!password) { + console.error('Error: ADMIN_PASSWORD environment variable is required'); + process.exit(1); + } const passwordHash = await bcrypt.hash(password, 10); if (PG_URL) { diff --git a/apps/backend/package.json b/apps/backend/package.json index 12fb476..6ae1587 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,6 +39,7 @@ "express": "^4.18.2", "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", + "helmet": "^8.1.0", "multer": "^2.2.0", "nodemailer": "^6.10.1", "pg": "^8.13.0", diff --git a/apps/frontend/nginx.conf b/apps/frontend/nginx.conf index bfc0155..cbfb814 100644 --- a/apps/frontend/nginx.conf +++ b/apps/frontend/nginx.conf @@ -1,3 +1,5 @@ +resolver 127.0.0.11 valid=10s ipv6=off; + server { listen 80; server_tokens off; @@ -10,7 +12,8 @@ server { add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; location /api/ { - proxy_pass http://backend:3001; + set $backend http://backend:3001; + proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx index f3e7b29..99a58d3 100644 --- a/apps/frontend/src/App.jsx +++ b/apps/frontend/src/App.jsx @@ -12,6 +12,7 @@ import ForgotPasswordModal from './components/ForgotPasswordModal'; import ResetPasswordView from './views/ResetPasswordView'; import SavedNotifications from './components/SavedNotifications'; import BottomNav from './components/BottomNav'; +import PrivacySidebar from './components/PrivacySidebar'; import CookieBanner from './components/CookieBanner'; import HealthConsentModal from './components/HealthConsentModal'; import PrivacyView from './views/PrivacyView'; @@ -323,6 +324,11 @@ function App() { badgeCount={badgeCount} /> + + {showLogin && ( ); })} - ); } diff --git a/apps/frontend/src/components/PrivacySidebar.css b/apps/frontend/src/components/PrivacySidebar.css new file mode 100644 index 0000000..8ee46ac --- /dev/null +++ b/apps/frontend/src/components/PrivacySidebar.css @@ -0,0 +1,78 @@ +.privacy-sidebar { + position: fixed; + left: 0; + top: 50%; + transform: translateY(-50%); + z-index: 40; + display: flex; + flex-direction: column; + align-items: center; +} + +.privacy-sidebar-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + width: 2.5rem; + padding: 0.75rem 0.25rem; + background: var(--surface-container-low); + border: none; + border-radius: 0 var(--radius-md) var(--radius-md) 0; + color: var(--on-surface-variant); + cursor: pointer; + transition: background 0.15s, color 0.15s; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08); + min-height: unset; +} + +.privacy-sidebar-btn:hover { + background: var(--surface-container); + color: var(--primary); +} + +.privacy-sidebar-btn:focus { + outline: none; + box-shadow: 0 0 0 3px var(--primary-ring), 2px 0 8px rgba(0, 0, 0, 0.08); +} + +.privacy-sidebar-text { + font-size: 0.55rem; + font-weight: 500; + writing-mode: vertical-rl; + text-orientation: mixed; + white-space: nowrap; + letter-spacing: 0.02em; +} + +/* Desktop - wider sidebar */ +@media (min-width: 1025px) { + .privacy-sidebar-btn { + width: 2.75rem; + padding: 1rem 0.35rem; + } + + .privacy-sidebar-text { + font-size: 0.6rem; + } +} + +/* Mobile - adjust for safe area */ +@media (max-width: 768px) { + .privacy-sidebar { + top: auto; + bottom: 6rem; + transform: none; + } + + .privacy-sidebar-btn { + width: 2.25rem; + padding: 0.5rem 0.2rem; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + } + + .privacy-sidebar-text { + font-size: 0.5rem; + } +} diff --git a/apps/frontend/src/components/PrivacySidebar.jsx b/apps/frontend/src/components/PrivacySidebar.jsx new file mode 100644 index 0000000..ea06e2e --- /dev/null +++ b/apps/frontend/src/components/PrivacySidebar.jsx @@ -0,0 +1,36 @@ +import { useTranslation } from '../i18n'; +import './PrivacySidebar.css'; + +function PrivacySidebar({ onNavigate, isVisible }) { + const { t } = useTranslation(); + + if (!isVisible) return null; + + return ( + + ); +} + +export default PrivacySidebar; diff --git a/apps/parapharmacy-api/src/routes/products.js b/apps/parapharmacy-api/src/routes/products.js index cd5394a..4a3415c 100644 --- a/apps/parapharmacy-api/src/routes/products.js +++ b/apps/parapharmacy-api/src/routes/products.js @@ -343,9 +343,21 @@ router.post('/bulk', requireServiceKey('INGEST_API_KEY'), async (req, res) => { */ router.put('/:id', requireServiceKey('ADMIN_API_KEY'), async (req, res) => { try { + // Allowlist: only these fields can be modified via PUT + const ALLOWED_FIELDS = [ + 'name', 'brand', 'category', 'subcategory', 'description', + 'image_url', 'source_url', 'price', 'original_price', 'source', + 'source_product_id', 'available', 'rating', 'review_count', + ]; + const update = {}; + for (const field of ALLOWED_FIELDS) { + if (field in req.body) update[field] = req.body[field]; + } + update.updated_at = new Date(); + const product = await Product.findByIdAndUpdate( req.params.id, - { ...req.body, updated_at: new Date() }, + update, { new: true } ); diff --git a/apps/pip-platform/src/infrastructure/config/settings.py b/apps/pip-platform/src/infrastructure/config/settings.py index 76328ae..b232eb0 100644 --- a/apps/pip-platform/src/infrastructure/config/settings.py +++ b/apps/pip-platform/src/infrastructure/config/settings.py @@ -15,7 +15,7 @@ class Settings(BaseSettings): DEBUG: bool = False NODE_ENV: str = "development" - DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip" + DATABASE_URL: str DATABASE_POOL_SIZE: int = 20 DATABASE_MAX_OVERFLOW: int = 10 DATABASE_POOL_RECYCLE: int = 3600 @@ -23,9 +23,9 @@ class Settings(BaseSettings): REDIS_URL: str = "redis://localhost:6379/0" REDIS_CACHE_TTL: int = 300 - RABBITMQ_URL: str = "amqp://pip:pip@localhost:5672/pip" + RABBITMQ_URL: str - JWT_SECRET_KEY: str = "change-me-in-production" + JWT_SECRET_KEY: str JWT_ALGORITHM: str = "HS256" JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 diff --git a/docker-compose.yml b/docker-compose.yml index f912bd3..cce32b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -158,6 +158,12 @@ services: mailpit: image: axllent/mailpit:latest restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:8025/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s ports: - "8025:8025" # Web UI - "1025:1025" # SMTP diff --git a/package-lock.json b/package-lock.json index d11186d..357c275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "express": "^4.18.2", "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", + "helmet": "^8.1.0", "multer": "^2.2.0", "nodemailer": "^6.10.1", "pg": "^8.13.0", @@ -17756,6 +17757,17 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/hermes-compiler": { "version": "250829098.0.14", "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.14.tgz",