diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-16 13:56:06 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-16 13:56:15 +0100 |
| commit | 8942cf3c705bbc78a6b3317599658e9bb86dd31b (patch) | |
| tree | c02c432dad7b31fa11160f16c221dfac45255920 | |
| parent | 3ce586243a49f34b36d0fe4099bbfb2631610f11 (diff) | |
Add legal document shenanigans
Closes #147
| -rw-r--r-- | docs/ROPA.md | 159 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Program.cs | 23 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs | 4 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs | 2 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/Drawer.tsx | 6 | ||||
| -rw-r--r-- | src/frontend/app/config/constants.ts | 1 | ||||
| -rw-r--r-- | src/frontend/app/contexts/MapContext.tsx | 16 | ||||
| -rw-r--r-- | src/frontend/app/data/StopDataProvider.ts | 93 | ||||
| -rw-r--r-- | src/frontend/app/routes.tsx | 1 | ||||
| -rw-r--r-- | src/frontend/app/routes/privacy.tsx | 375 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.tsx | 82 | ||||
| -rw-r--r-- | src/frontend/public/maps/styles/openfreemap-light.json | 6 |
12 files changed, 688 insertions, 80 deletions
diff --git a/docs/ROPA.md b/docs/ROPA.md new file mode 100644 index 0000000..dc49b25 --- /dev/null +++ b/docs/ROPA.md @@ -0,0 +1,159 @@ +# Registro de Actividades de Tratamiento (ROPA) + +**Artículo 30 del RGPD (UE) 2016/679 · Ley Orgánica 3/2018 (LOPDGDD)** + +| Campo | Valor | +|---|---| +| **Responsable del tratamiento** | Ariel Costas Guerrero | +| **Contacto** | <privacidad@enmarcha.app> | +| **Fecha de elaboración** | 16 de marzo de 2026 | +| **Versión** | 1.0 | + +--- + +## Actividades de tratamiento + +### AT-01 · Consulta de llegadas a una parada + +| Campo | Detalle | +|---|---| +| **Finalidad** | Mostrar las próximas llegadas de autobús a la parada consultada | +| **Categoría de datos** | Código numérico de parada (no personal) | +| **Interesados** | Usuarios de la aplicación | +| **Base jurídica** | Interés legítimo — prestación del servicio solicitado (art. 6.1.f RGPD) | +| **Plazo de conservación** | No se almacena en servidor; caché en memoria con TTL de 15 min | +| **Destinatarios** | Vitrasa/Concello de Vigo (dados.vigo.org), TUSSA (app.tussa.org), Tranvías Coruña (itranvias.com), CTAG Shuttle, Renfe GTFS-Realtime | +| **Transferencias internacionales** | Ninguna | + +--- + +### AT-02 · Planificación de rutas + +| Campo | Detalle | +|---|---| +| **Finalidad** | Calcular itinerarios de transporte público entre dos puntos | +| **Categoría de datos** | Coordenadas de origen y destino (WGS84), hora de viaje, preferencia salida/llegada | +| **Interesados** | Usuarios de la aplicación | +| **Base jurídica** | Interés legítimo — prestación del servicio solicitado (art. 6.1.f RGPD) | +| **Plazo de conservación** | No se almacena en servidor; caché en localStorage del dispositivo con TTL de 2 h | +| **Destinatarios** | OpenTripPlanner (URL configurada en backend) | +| **Transferencias internacionales** | Depende de dónde se aloje OpenTripPlanner | + +--- + +### AT-03 · Geocodificación y geocodificación inversa + +| Campo | Detalle | +|---|---| +| **Finalidad** | Convertir texto de búsqueda en coordenadas, o coordenadas en nombre de lugar | +| **Categoría de datos** | Texto de búsqueda libre O coordenadas lat/lon | +| **Interesados** | Usuarios de la aplicación | +| **Base jurídica** | Interés legítimo — prestación del servicio solicitado (art. 6.1.f RGPD) | +| **Plazo de conservación** | No se almacena en servidor; caché en memoria con TTL de 60 min | +| **Destinatarios** | Geoapify (api.geoapify.com) — ver política en geoapify.com/privacy-policy | +| **Transferencias internacionales** | Posible transferencia a servidores de Geoapify fuera del EEE; Geoapify tiene Privacy Shield / SCCs | + +--- + +### AT-04 · Paradas favoritas y nombres personalizados + +| Campo | Detalle | +|---|---| +| **Finalidad** | Recordar las paradas que el usuario marca como favoritas y los nombres que les asigna | +| **Categoría de datos** | Identificadores de parada (p. ej. `vitrasa:1400`), nombres de texto libre introducidos por el usuario | +| **Interesados** | Usuarios de la aplicación | +| **Base jurídica** | Acción propia del interesado (función solicitada voluntariamente, art. 6.1.a/f RGPD) | +| **Plazo de conservación** | Indefinido en localStorage del dispositivo; el usuario puede borrarlos en cualquier momento | +| **Destinatarios** | Nadie — solo localStorage del dispositivo del usuario | +| **Transferencias internacionales** | Ninguna | + +--- + +### AT-05 · Paradas y lugares recientes + +| Campo | Detalle | +|---|---| +| **Finalidad** | Mostrar sugerencias de paradas y búsquedas recientes para agilizar el uso | +| **Categoría de datos** | Códigos de parada, coordenadas de búsquedas del planificador, nombres de lugares | +| **Interesados** | Usuarios de la aplicación | +| **Base jurídica** | Interés legítimo — facilitar el uso recurrente de la aplicación (art. 6.1.f RGPD) | +| **Plazo de conservación** | Indefinido en localStorage; máx. 20 lugares / 10 paradas; el usuario puede borrarlos | +| **Destinatarios** | Nadie — solo localStorage del dispositivo del usuario | +| **Transferencias internacionales** | Ninguna | + +--- + +### AT-06 · Ubicaciones de casa y trabajo + +| Campo | Detalle | +|---|---| +| **Finalidad** | Permitir que el usuario configure atajos hacia sus ubicaciones habituales | +| **Categoría de datos** | Nombre descriptivo, tipo (parada o dirección), coordenadas lat/lon | +| **Interesados** | Usuarios de la aplicación | +| **Base jurídica** | Acción propia del interesado (función configurada voluntariamente, art. 6.1.f RGPD) | +| **Plazo de conservación** | Indefinido en localStorage; el usuario puede borrarlos en cualquier momento | +| **Destinatarios** | Nadie — solo localStorage del dispositivo del usuario | +| **Transferencias internacionales** | Ninguna | + +--- + +### AT-07 · Posición GPS del mapa + +| Campo | Detalle | +|---|---| +| **Finalidad** | Centrar el mapa en la posición del usuario; calcular paradas cercanas | +| **Categoría de datos** | Coordenadas GPS (lat/lon) | +| **Interesados** | Usuarios de la aplicación que conceden permiso de geolocalización | +| **Base jurídica** | Consentimiento del interesado a través del permiso de geolocalización del navegador (art. 6.1.a RGPD) | +| **Plazo de conservación** | 30 días en localStorage; se descarta automáticamente al superarse | +| **Destinatarios** | Solo se transmite al servidor si el usuario inicia una planificación de ruta desde su posición actual (AT-02) | +| **Transferencias internacionales** | Ninguna (salvo si aplica AT-02) | + +--- + +### AT-08 · Registros operativos del servidor + +| Campo | Detalle | +|---|---| +| **Finalidad** | Diagnóstico de errores, monitorización de disponibilidad y seguridad | +| **Categoría de datos** | Dirección IP **anonimizada** (IPv4: /24; IPv6: /48), identificadores de parada/ruta, método HTTP, código de respuesta | +| **Interesados** | Cualquier usuario que acceda a la API backend | +| **Base jurídica** | Interés legítimo — operación segura del servicio (art. 6.1.f RGPD) | +| **Plazo de conservación** | Rotación estándar de logs del servidor (típicamente 7-30 días) | +| **Destinatarios** | Solo el responsable del tratamiento | +| **Transferencias internacionales** | Depende del proveedor de hosting | +| **Nota técnica** | Los IPs se truncan _antes_ de que entren en el pipeline de logging mediante middleware de ASP.NET Core | + +--- + +### AT-09 · Telemetría de rendimiento (OpenTelemetry) + +| Campo | Detalle | +|---|---| +| **Finalidad** | Monitorización de rendimiento, trazabilidad de errores | +| **Categoría de datos** | Identificadores de parada/ruta, duración de peticiones, códigos de estado — **sin coordenadas ni IPs completas** | +| **Interesados** | Cualquier usuario que acceda a la API backend | +| **Base jurídica** | Interés legítimo — mejora del servicio (art. 6.1.f RGPD) | +| **Plazo de conservación** | Según configuración del colector OTLP (si se activa) | +| **Destinatarios** | Colector OTLP autogestionado o tercero (p. ej. Grafana Cloud) si se configura | +| **Transferencias internacionales** | Posible si se usa SaaS de telemetría fuera del EEE | +| **Nota técnica** | Las coordenadas (lat/lon) se han eliminado de los atributos de span; las IPs se anonymizan vía `EnrichWithHttpRequest` | + +--- + +## Medidas técnicas y organizativas + +| Medida | Descripción | +|---|---| +| Anonimización de IPs | IPv4 → último octeto = 0; IPv6 → últimos 80 bits = 0 (antes de logs y spans) | +| Sin coordenadas en spans | El atributo `lat`/`lon` se ha eliminado de los spans de OpenTelemetry | +| User-Agent sin email | El encabezado User-Agent enviado a Geoapify y Nominatim no contiene datos personales del responsable | +| Expiración automática de ubicación | La posición GPS guardada en localStorage se descarta tras 30 días | +| Borrado por el usuario | Los usuarios pueden eliminar todos sus datos locales desde Ajustes → Privacidad y datos | +| Sin cuentas de usuario | No se crean perfiles, contraseñas ni identificadores persistentes de usuario | +| Sin cookies de seguimiento | Solo se usa `localStorage` de primer nivel y, opcionalmente, una cookie de idioma | +| Política de privacidad pública | Disponible en `/politica-privacidad` dentro de la aplicación | + +--- + +_Documento interno para uso del responsable del tratamiento. No es un documento público._ diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs index 6eddfc8..587da78 100644 --- a/src/Enmarcha.Backend/Program.cs +++ b/src/Enmarcha.Backend/Program.cs @@ -46,7 +46,28 @@ builder.Services.AddOpenTelemetry() tracing .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("Enmarcha.Backend")) .AddSource(Telemetry.Source.Name) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(options => + { + options.EnrichWithHttpRequest = (activity, request) => + { + var ip = request.HttpContext.Connection.RemoteIpAddress; + if (ip == null) return; + string anonymised; + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + var bytes = ip.GetAddressBytes(); + bytes[3] = 0; + anonymised = new System.Net.IPAddress(bytes).ToString(); + } + else + { + var bytes = ip.GetAddressBytes(); + for (var i = 6; i < 16; i++) bytes[i] = 0; + anonymised = new System.Net.IPAddress(bytes).ToString(); + } + activity.SetTag("client.address", anonymised); + }; + }) .AddHttpClientInstrumentation(options => { options.EnrichWithHttpRequestMessage = (activity, req) => diff --git a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs index 86386e8..ce86c49 100644 --- a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs +++ b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs @@ -26,7 +26,7 @@ public class GeoapifyGeocodingService : IGeocodingService // Geoapify requires a User-Agent if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) { - _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; contacto@enmarcha.app)"); } } @@ -76,8 +76,6 @@ public class GeoapifyGeocodingService : IGeocodingService public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon) { using var activity = Telemetry.Source.StartActivity("GeoapifyReverseGeocode"); - activity?.SetTag("lat", lat); - activity?.SetTag("lon", lon); var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}"; var cacheHit = _cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult); diff --git a/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs index c38b1e6..b58b5a4 100644 --- a/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs +++ b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs @@ -26,7 +26,7 @@ public class NominatimGeocodingService : IGeocodingService // Nominatim requires a User-Agent if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) { - _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; contacto@enmarcha.app)"); } } diff --git a/src/frontend/app/components/layout/Drawer.tsx b/src/frontend/app/components/layout/Drawer.tsx index 55aa3a0..3f7b4d5 100644 --- a/src/frontend/app/components/layout/Drawer.tsx +++ b/src/frontend/app/components/layout/Drawer.tsx @@ -1,4 +1,4 @@ -import { Info, Settings, Star, X } from "lucide-react"; +import { Info, Settings, Shield, Star, X } from "lucide-react"; import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; @@ -45,6 +45,10 @@ export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose }) => { <Info size={20} /> <span>{t("about.title", "Acerca de")}</span> </Link> + <Link to="/politica-privacidad" className="drawer__link"> + <Shield size={20} /> + <span>{t("navbar.privacy", "Privacidad")}</span> + </Link> </nav> </div> </> diff --git a/src/frontend/app/config/constants.ts b/src/frontend/app/config/constants.ts index 3cbb37c..5ede933 100644 --- a/src/frontend/app/config/constants.ts +++ b/src/frontend/app/config/constants.ts @@ -1,7 +1,6 @@ import type { LngLatLike } from "maplibre-gl"; export const APP_CONSTANTS = { - id: "vigo", defaultCenter: { lat: 42.229188855975046, lng: -8.72246955783102, diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx index 75851f4..d93fefc 100644 --- a/src/frontend/app/contexts/MapContext.tsx +++ b/src/frontend/app/contexts/MapContext.tsx @@ -25,15 +25,23 @@ interface MapContextProps { const MapContext = createContext<MapContextProps | undefined>(undefined); +const LOCATION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + export const MapProvider = ({ children }: { children: ReactNode }) => { const [mapState, setMapState] = useState<MapState>(() => { const savedMapState = localStorage.getItem("mapState"); if (savedMapState) { try { const parsed = JSON.parse(savedMapState); + const locationAge = parsed.userLocationTimestamp + ? Date.now() - parsed.userLocationTimestamp + : Infinity; return { paths: parsed.paths || {}, - userLocation: parsed.userLocation || null, + userLocation: + locationAge < LOCATION_TTL_MS + ? (parsed.userLocation ?? null) + : null, hasLocationPermission: parsed.hasLocationPermission || false, }; } catch (e) { @@ -51,7 +59,11 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { const setUserLocation = useCallback((userLocation: LngLatLike | null) => { setMapState((prev) => { - const newState = { ...prev, userLocation }; + const newState = { + ...prev, + userLocation, + userLocationTimestamp: userLocation ? Date.now() : null, + }; localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 76182c7..d8219c9 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,5 +1,3 @@ -import { APP_CONSTANTS } from "~/config/constants"; - export interface Stop { stopId: string; stopCode?: string; @@ -20,14 +18,14 @@ interface CacheEntry { timestamp: number; } -const CACHE_KEY = `stops_cache_${APP_CONSTANTS.id}`; +const CACHE_KEY = `stops_cache`; const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours // In-memory cache for the current session const memoryCache: Record<string, Stop> = {}; // Custom names loaded from localStorage per region -const customNamesByRegion: Record<string, Record<string, string>> = {}; +let customNames: Record<string, string> = {}; // Helper to normalize ID function normalizeId(id: number | string): string { @@ -128,58 +126,38 @@ function getDisplayName(stop: Stop): string { function setCustomName(stopId: string | number, label: string) { const id = normalizeId(stopId); - if (!customNamesByRegion[APP_CONSTANTS.id]) { - const rawCustom = localStorage.getItem( - `customStopNames_${APP_CONSTANTS.id}` - ); - customNamesByRegion[APP_CONSTANTS.id] = rawCustom - ? JSON.parse(rawCustom) - : {}; + if (!customNames) { + const rawCustom = localStorage.getItem(`customStopNames`); + customNames = rawCustom ? JSON.parse(rawCustom) : {}; } - customNamesByRegion[APP_CONSTANTS.id][id] = label; - localStorage.setItem( - `customStopNames_${APP_CONSTANTS.id}`, - JSON.stringify(customNamesByRegion[APP_CONSTANTS.id]) - ); + customNames[id] = label; + localStorage.setItem(`customStopNames`, JSON.stringify(customNames)); } function removeCustomName(stopId: string | number) { const id = normalizeId(stopId); - if (!customNamesByRegion[APP_CONSTANTS.id]) { - const rawCustom = localStorage.getItem( - `customStopNames_${APP_CONSTANTS.id}` - ); - customNamesByRegion[APP_CONSTANTS.id] = rawCustom - ? JSON.parse(rawCustom) - : {}; + if (!customNames) { + const rawCustom = localStorage.getItem(`customStopNames`); + customNames = rawCustom ? JSON.parse(rawCustom) : {}; } - if (customNamesByRegion[APP_CONSTANTS.id][id]) { - delete customNamesByRegion[APP_CONSTANTS.id][id]; - localStorage.setItem( - `customStopNames_${APP_CONSTANTS.id}`, - JSON.stringify(customNamesByRegion[APP_CONSTANTS.id]) - ); + if (customNames[id]) { + delete customNames[id]; + localStorage.setItem(`customStopNames`, JSON.stringify(customNames)); } } function getCustomName(stopId: string | number): string | undefined { const id = normalizeId(stopId); - if (!customNamesByRegion[APP_CONSTANTS.id]) { - const rawCustom = localStorage.getItem( - `customStopNames_${APP_CONSTANTS.id}` - ); - customNamesByRegion[APP_CONSTANTS.id] = rawCustom - ? JSON.parse(rawCustom) - : {}; + if (!customNames) { + const rawCustom = localStorage.getItem(`customStopNames`); + customNames = rawCustom ? JSON.parse(rawCustom) : {}; } - return customNamesByRegion[APP_CONSTANTS.id][id]; + return customNames[id]; } function addFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -189,18 +167,13 @@ function addFavourite(stopId: string | number) { if (!favouriteStops.includes(id)) { favouriteStops.push(id); - localStorage.setItem( - `favouriteStops_${APP_CONSTANTS.id}`, - JSON.stringify(favouriteStops) - ); + localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops)); } } function removeFavourite(stopId: string | number) { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); let favouriteStops: string[] = []; if (rawFavouriteStops) { favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map( @@ -209,17 +182,12 @@ function removeFavourite(stopId: string | number) { } const newFavouriteStops = favouriteStops.filter((sid) => sid !== id); - localStorage.setItem( - `favouriteStops_${APP_CONSTANTS.id}`, - JSON.stringify(newFavouriteStops) - ); + localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops)); } function isFavourite(stopId: string | number): boolean { const id = normalizeId(stopId); - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); if (rawFavouriteStops) { const favouriteStops = ( JSON.parse(rawFavouriteStops) as (number | string)[] @@ -233,9 +201,7 @@ const RECENT_STOPS_LIMIT = 10; function pushRecent(stopId: string | number) { const id = normalizeId(stopId); - const rawRecentStops = localStorage.getItem( - `recentStops_${APP_CONSTANTS.id}` - ); + const rawRecentStops = localStorage.getItem(`recentStops`); let recentStops: string[] = []; if (rawRecentStops) { recentStops = (JSON.parse(rawRecentStops) as (number | string)[]).map( @@ -251,16 +217,11 @@ function pushRecent(stopId: string | number) { recentStops = recentStops.slice(0, RECENT_STOPS_LIMIT); } - localStorage.setItem( - `recentStops_${APP_CONSTANTS.id}`, - JSON.stringify(recentStops) - ); + localStorage.setItem(`recentStops`, JSON.stringify(recentStops)); } function getRecent(): string[] { - const rawRecentStops = localStorage.getItem( - `recentStops_${APP_CONSTANTS.id}` - ); + const rawRecentStops = localStorage.getItem(`recentStops`); if (rawRecentStops) { return (JSON.parse(rawRecentStops) as (number | string)[]).map(normalizeId); } @@ -268,9 +229,7 @@ function getRecent(): string[] { } function getFavouriteIds(): string[] { - const rawFavouriteStops = localStorage.getItem( - `favouriteStops_${APP_CONSTANTS.id}` - ); + const rawFavouriteStops = localStorage.getItem(`favouriteStops`); if (rawFavouriteStops) { return (JSON.parse(rawFavouriteStops) as (number | string)[]).map( normalizeId diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 8e98734..7c02728 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -11,4 +11,5 @@ export default [ route("/about", "routes/about.tsx"), route("/favourites", "routes/favourites.tsx"), route("/planner", "routes/planner.tsx"), + route("/politica-privacidad", "routes/privacy.tsx"), ] satisfies RouteConfig; diff --git a/src/frontend/app/routes/privacy.tsx b/src/frontend/app/routes/privacy.tsx new file mode 100644 index 0000000..2998223 --- /dev/null +++ b/src/frontend/app/routes/privacy.tsx @@ -0,0 +1,375 @@ +import { usePageTitle } from "~/contexts/PageTitleContext"; +import "../tailwind-full.css"; + +export default function Privacy() { + usePageTitle("Política de privacidad"); + + return ( + <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> + <h1 className="text-3xl font-bold mb-2 text-text"> + Política de privacidad + </h1> + <p className="text-sm text-muted mb-8"> + Última actualización: 16 de marzo de 2026 + </p> + + {/* 1. Responsable */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 1. Responsable del tratamiento + </h2> + <p className="text-text opacity-90 leading-relaxed"> + El responsable del tratamiento de los datos personales recogidos a + través de esta aplicación es: + </p> + <ul className="mt-3 space-y-1 text-text opacity-90 list-none ml-0"> + <li> + <strong>Nombre:</strong> Ariel Costas Guerrero + </li> + <li> + <strong>Correo de contacto:</strong>{" "} + <a + href="mailto:privacidad@enmarcha.app" + className="text-blue-600 dark:text-blue-400 hover:underline" + > + privacidad@enmarcha.app + </a> + </li> + </ul> + </section> + + {/* 2. Datos tratados */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 2. Datos que recogemos y por qué + </h2> + <p className="text-text opacity-90 mb-4"> + Esta aplicación es un servicio de consulta de transporte público. No + se crean cuentas de usuario ni se realiza ningún tipo de seguimiento + publicitario. + </p> + + <div className="overflow-x-auto"> + <table className="w-full text-sm text-text border-collapse bg-surface rounded shadow overflow-hidden"> + <thead> + <tr className="border-b border-border"> + <th className="text-left p-3 font-semibold">Actividad</th> + <th className="text-left p-3 font-semibold">Datos</th> + <th className="text-left p-3 font-semibold">Base jurídica</th> + <th className="text-left p-3 font-semibold">Conservación</th> + </tr> + </thead> + <tbody> + <tr className="border-b border-border"> + <td className="p-3">Consulta de llegadas a una parada</td> + <td className="p-3">Código de parada (no personal)</td> + <td className="p-3"> + Interés legítimo (prestación del servicio) + </td> + <td className="p-3">No se almacena</td> + </tr> + <tr className="border-b border-border bg-surface/40"> + <td className="p-3">Planificación de rutas</td> + <td className="p-3"> + Coordenadas de origen y destino, hora deseada + </td> + <td className="p-3"> + Interés legítimo (prestación del servicio) + </td> + <td className="p-3">2 horas (caché local en tu dispositivo)</td> + </tr> + <tr className="border-b border-border"> + <td className="p-3"> + Geocodificación / geocodificación inversa + </td> + <td className="p-3">Coordenadas o texto de búsqueda</td> + <td className="p-3"> + Interés legítimo (prestación del servicio) + </td> + <td className="p-3">No se almacena en servidor</td> + </tr> + <tr className="border-b border-border bg-surface/40"> + <td className="p-3"> + Paradas favoritas y nombres personalizados + </td> + <td className="p-3"> + Identificadores de parada, nombres que tú asignas + </td> + <td className="p-3"> + Acción del usuario (guardado voluntario) + </td> + <td className="p-3">Hasta que los borres tú</td> + </tr> + <tr className="border-b border-border"> + <td className="p-3">Paradas y lugares recientes</td> + <td className="p-3"> + Códigos de parada, coordenadas de búsquedas recientes + </td> + <td className="p-3">Interés legítimo (facilitar el uso)</td> + <td className="p-3">Persistente hasta que los borres tú</td> + </tr> + <tr className="border-b border-border bg-surface/40"> + <td className="p-3">Ubicaciones de casa y trabajo</td> + <td className="p-3">Dirección descriptiva y coordenadas</td> + <td className="p-3"> + Acción del usuario (configurado voluntariamente) + </td> + <td className="p-3">Hasta que los borres tú</td> + </tr> + <tr className="border-b border-border"> + <td className="p-3">Posición GPS del mapa</td> + <td className="p-3">Coordenadas GPS de tu dispositivo</td> + <td className="p-3"> + Consentimiento (permiso de geolocalización del navegador) + </td> + <td className="p-3"> + 30 días (caché local; se descarta automáticamente) + </td> + </tr> + <tr className="border-b border-border bg-surface/40"> + <td className="p-3">Registros operativos del servidor</td> + <td className="p-3"> + Dirección IP anonimizada, identificadores de parada/ruta + </td> + <td className="p-3"> + Interés legítimo (operación y seguridad) + </td> + <td className="p-3">Rotación estándar de logs del servidor</td> + </tr> + <tr className="border-b border-border"> + <td className="p-3">Telemetría de rendimiento</td> + <td className="p-3"> + Identificadores de parada/ruta (sin coordenadas) + </td> + <td className="p-3">Interés legítimo (mejora del servicio)</td> + <td className="p-3">Según configuración del colector OTLP</td> + </tr> + </tbody> + </table> + </div> + </section> + + {/* 3. Geolocalización */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 3. Geolocalización + </h2> + <p className="text-text opacity-90 leading-relaxed"> + La aplicación puede solicitar acceso a tu ubicación GPS a través del + permiso de geolocalización del navegador. Este acceso es opcional: si + lo denegas, la aplicación seguirá funcionando sin centrar el mapa en + tu posición. + </p> + <p className="mt-3 text-text opacity-90 leading-relaxed"> + Tus coordenadas <strong>no se transmiten al servidor</strong> salvo + cuando usas el planificador de rutas para calcular un trayecto desde + tu ubicación actual. En ese caso, las coordenadas se envían al + servidor únicamente para procesar la consulta y no se almacenan. + </p> + <p className="mt-3 text-text opacity-90 leading-relaxed"> + La posición guardada localmente en tu dispositivo se descarta + automáticamente tras 30 días. + </p> + </section> + + {/* 4. Almacenamiento local */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 4. Almacenamiento local (localStorage) + </h2> + <p className="text-text opacity-90 leading-relaxed"> + Esta aplicación utiliza el almacenamiento local del navegador ( + <code className="bg-surface px-1 rounded text-sm">localStorage</code>) + para guardar tus preferencias, favoritos e historial de uso. Este + almacenamiento <strong>nunca se comparte con terceros</strong> y es de + carácter estrictamente funcional, no de seguimiento ni publicidad. + </p> + <p className="mt-3 text-text opacity-90 leading-relaxed"> + No se usan <em>cookies</em> de sesión ni de rastreo. La única + excepción es la preferencia de idioma, que puede almacenarse tanto en{" "} + <code className="bg-surface px-1 rounded text-sm">localStorage</code>{" "} + como en una cookie de primer nivel para mantener la selección entre + visitas. + </p> + <p className="mt-3 text-text opacity-90 leading-relaxed"> + Puedes eliminar todos los datos personales guardados localmente en + cualquier momento desde{" "} + <strong> + Ajustes → Privacidad y datos → Borrar mis datos guardados + </strong> + . + </p> + </section> + + {/* 5. Encargados del tratamiento */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 5. Encargados y destinatarios del tratamiento + </h2> + <p className="text-text opacity-90 mb-4"> + Para prestar el servicio, algunas de tus consultas se transmiten a los + siguientes terceros. En ningún caso se transfiere información que + permita identificarte personalmente como usuario registrado, ya que la + aplicación no tiene sistema de cuentas. + </p> + <ul className="space-y-3 text-text opacity-90 list-disc ml-5"> + <li> + <strong>Geoapify</strong> (api.geoapify.com) — buscador de + direcciones y geocodificación inversa. Recibe el texto de búsqueda o + las coordenadas de la consulta. Política de privacidad:{" "} + <a + href="https://www.geoapify.com/privacy-policy" + className="text-blue-600 dark:text-blue-400 hover:underline" + rel="nofollow noreferrer noopener" + target="_blank" + > + geoapify.com/privacy-policy + </a> + </li> + <li> + <strong>OpenTripPlanner</strong> — motor de planificación de rutas. + Recibe las coordenadas de origen y destino y la hora del viaje. + </li> + <li> + <strong>Vitrasa / Concello de Vigo</strong> (datos.vigo.org) — datos + de llegadas en tiempo real para Vigo. Solo recibe el código numérico + de la parada, y un resultado puede ser utilizado para varios + usuarios, de modo que no se asocia a un individuo concreto. + </li> + <li> + <strong>TUSSA</strong> (app.tussa.org) — datos de llegadas en tiempo + real para Santiago de Compostela. Solo recibe el código de la + parada, y un resultado puede ser utilizado para varios usuarios, de + modo que no se asocia a un individuo concreto. + </li> + <li> + <strong>Tranvías de A Coruña</strong> (itranvias.com) — datos de + llegadas en tiempo real para A Coruña. Solo recibe el código de la + parada, y un resultado puede ser utilizado para varios usuarios, de + modo que no se asocia a un individuo concreto. + </li> + <li> + <strong>Renfe GTFS-Realtime</strong> — feed público de posiciones de + trenes. No se envían datos del usuario. + </li> + </ul> + </section> + + {/* 6. Derechos */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 6. Tus derechos + </h2> + <p className="text-text opacity-90 mb-3 leading-relaxed"> + De acuerdo con el Reglamento General de Protección de Datos (RGPD) y + la Ley Orgánica 3/2018 de Protección de Datos Personales y garantía de + los derechos digitales (LOPDGDD), tienes derecho a: + </p> + <ul className="space-y-2 text-text opacity-90 list-disc ml-5"> + <li> + <strong>Acceso</strong>: conocer qué datos tratamos sobre ti (los + datos que mencionamos previamente). + </li> + <li> + <strong>Rectificación</strong>: solicitar la corrección de datos + inexactos (puedes hacerlo a través de la aplicación). + </li> + <li> + <strong>Supresión</strong>: solicitar la eliminación de tus datos + («derecho al olvido»). Los datos guardados en el dispositivo los + debes eliminar manualmente, los datos en servidores se eliminan tras + el periodo de consevación indicado, y no se pueden eliminar + previamente por no ser asociados a un usuario concreto. + </li> + <li> + <strong>Oposición</strong>: oponerte al tratamiento basado en + interés legítimo. Dado que el tratamiento de datos en esta + aplicación se basa principalmente en el interés legítimo para + prestar el servicio, no es posible ejercer este derecho sin dejar de + usar la aplicación, o las funciones que implican este tratamiento + (planificador de rutas con "ubicación actual", por ejemplo). + </li> + <li> + <strong>Portabilidad</strong>: recibir tus datos en un formato + estructurado. Los datos están exclusivamente en el dispositivo, + basta con acceder mediante las herramientas de desarrollo del + navegador para copiarlos, o visualizarlos en la aplicación. + </li> + <li> + <strong>Limitación</strong>: solicitar que restrinjamos el + tratamiento de tus datos. Dado que la aplicación no almacena datos + personales en servidores de forma persistente, este derecho se puede + ejercer eliminando los datos desde la propia aplicación o dejando de + usar las funciones que implican el tratamiento. + </li> + </ul> + <p className="mt-4 text-text opacity-90 leading-relaxed"> + Dado que la aplicación no almacena datos personales en ningún servidor + de forma persistente (todo lo personal reside en tu propio + dispositivo), la mayoría de estos derechos los puedes ejercer + directamente borrando los datos desde la propia aplicación (ver + sección 4). + </p> + <p className="mt-3 text-text opacity-90 leading-relaxed"> + Para cualquier consulta o solicitud formal, puedes dirigirte a{" "} + <a + href="mailto:privacidad@enmarcha.app" + className="text-blue-600 dark:text-blue-400 hover:underline" + > + privacidad@enmarcha.app + </a> + . + </p> + </section> + + {/* 7. AEPD */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 7. Derecho a reclamar ante la autoridad de control + </h2> + <p className="text-text opacity-90 leading-relaxed"> + Si consideras que el tratamiento de tus datos no se ajusta a la + normativa vigente, puedes presentar una reclamación ante la{" "} + <strong>Agencia Española de Protección de Datos (AEPD)</strong>: + </p> + <ul className="mt-3 space-y-1 text-text opacity-90 list-none ml-0"> + <li> + Web:{" "} + <a + href="https://www.aepd.es" + className="text-blue-600 dark:text-blue-400 hover:underline" + rel="nofollow noreferrer noopener" + target="_blank" + > + www.aepd.es + </a> + </li> + <li> + Sede electrónica:{" "} + <a + href="https://sedeagpd.gob.es" + className="text-blue-600 dark:text-blue-400 hover:underline" + rel="nofollow noreferrer noopener" + target="_blank" + > + sedeagpd.gob.es + </a> + </li> + </ul> + </section> + + {/* 8. Cambios */} + <section className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text"> + 8. Cambios en esta política + </h2> + <p className="text-text opacity-90 leading-relaxed"> + Esta política puede actualizarse para reflejar cambios en la + aplicación o en la normativa aplicable. La fecha de «Última + actualización» indicada en la cabecera refleja la versión vigente. Te + recomendamos revisarla periódicamente. + </p> + </section> + </div> + ); +} diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index e7fdffa..0497f34 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -1,5 +1,7 @@ -import { Computer, Moon, Sun } from "lucide-react"; +import { Computer, Moon, Sun, Trash2 } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp, type Theme } from "../AppContext"; import "../tailwind-full.css"; @@ -7,6 +9,8 @@ import "../tailwind-full.css"; export default function Settings() { const { t, i18n } = useTranslation(); usePageTitle(t("navbar.settings", "Ajustes")); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [cleared, setCleared] = useState(false); const { theme, setTheme, @@ -192,6 +196,82 @@ export default function Settings() { <option value="en-GB">English</option> </select> </section> + + {/* Privacy / Clear data */} + <section className="mt-8 pt-8 border-t border-border"> + <h2 className="text-xl font-semibold mb-4 text-text"> + {t("settings.privacy_title", "Privacidad y datos")} + </h2> + {!showClearConfirm && !cleared && ( + <button + onClick={() => setShowClearConfirm(true)} + className="flex items-center gap-2 px-4 py-3 rounded-lg border border-border + bg-surface text-text hover:bg-red-50 hover:border-red-300 hover:text-red-700 + dark:hover:bg-red-950 dark:hover:border-red-700 dark:hover:text-red-400 + transition-colors duration-200 focus:outline-none focus:ring focus:ring-red-300" + > + <Trash2 className="w-5 h-5" /> + {t("settings.clear_data", "Borrar mis datos guardados")} + </button> + )} + {showClearConfirm && ( + <div className="p-4 rounded-lg border border-red-300 bg-red-50 dark:bg-red-950 dark:border-red-700"> + <p className="text-sm text-text mb-4"> + {t( + "settings.clear_data_confirm", + "Se eliminarán tus paradas favoritas, nombres personalizados, paradas recientes, historial de rutas, lugares guardados y posición del mapa. Las preferencias de ajustes se conservarán." + )} + </p> + <div className="flex gap-3"> + <button + onClick={() => { + const personalKeys = [ + "mapState", + "specialPlace_home", + "specialPlace_work", + "recentPlaces", + "planner_route_history", + `favouriteStops`, + `recentStops`, + `customStopNames`, + `stops_cache`, + ]; + personalKeys.forEach((key) => localStorage.removeItem(key)); + setShowClearConfirm(false); + setCleared(true); + }} + className="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 + transition-colors duration-200 focus:outline-none focus:ring focus:ring-red-300" + > + {t("settings.clear_data_confirm_btn", "Sí, borrar datos")} + </button> + <button + onClick={() => setShowClearConfirm(false)} + className="px-4 py-2 rounded-lg border border-border bg-surface text-text + hover:bg-surface/50 transition-colors duration-200 focus:outline-none focus:ring focus:ring-primary/50" + > + {t("settings.cancel", "Cancelar")} + </button> + </div> + </div> + )} + {cleared && ( + <p className="text-sm text-green-700 dark:text-green-400"> + {t( + "settings.clear_data_done", + "Tus datos se han borrado correctamente." + )} + </p> + )} + <p className="mt-4 text-sm text-muted"> + <Link + to="/politica-privacidad" + className="underline hover:text-text transition-colors" + > + {t("settings.privacy_policy_link", "Política de privacidad")} + </Link> + </p> + </section> </div> ); } diff --git a/src/frontend/public/maps/styles/openfreemap-light.json b/src/frontend/public/maps/styles/openfreemap-light.json index da5a788..18053f6 100644 --- a/src/frontend/public/maps/styles/openfreemap-light.json +++ b/src/frontend/public/maps/styles/openfreemap-light.json @@ -3,15 +3,15 @@ "sources": { "openmaptiles": { "type": "vector", - "url": "https://tiles.openfreemap.org/planet" + "url": "https://enmarcha.app/ofm/planet" }, "vigo_traffic": { "type": "geojson", "data": "/api/traffic" } }, - "sprite": "https://tiles.openfreemap.org/sprites/ofm_f384/ofm", - "glyphs": "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf", + "sprite": "https://enmarcha.app/ofm/sprites/ofm_f384/ofm", + "glyphs": "https://enmarcha.app/ofm/fonts/{fontstack}/{range}.pbf", "layers": [ { "id": "background", |
