diff --git a/.env.example b/.env.example
index a2ca997..6846b82 100644
--- a/.env.example
+++ b/.env.example
@@ -19,9 +19,17 @@ EXPO_PUBLIC_POSTHOG_API_KEY=phc_XXXXXXXXXXXXXXXXXXXX
### Invite HTTPS host (Firebase Hosting default domain, no protocol)
# Used for WhatsApp share links: https://HOST/m/{id} and https://HOST/t/{id}
-# eas env:create --name EXPO_PUBLIC_INVITE_HOST --value "musapp-731e1.web.app" --environment production --visibility plain
+# eas env:create --name EXPO_PUBLIC_INVITE_HOST --value "musapp-731e1.web.app" --environment production --visibility plaintext
EXPO_PUBLIC_INVITE_HOST=musapp-731e1.web.app
+### Cloudflare Turnstile (Dashboard → Turnstile → widget → Site Key)
+# Public site key only. The Secret Key goes in Supabase Auth → Bot and Abuse Protection, never here.
+# Hostnames on the widget (no https://): localhost (Expo Go / web) and musapp-731e1.web.app (release).
+# eas env:create production --name EXPO_PUBLIC_TURNSTILE_SITE_KEY --value "0x4AAAAA..." --type string --visibility plaintext
+EXPO_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAA_YOUR_SITE_KEY
+# Optional override of the production native page host (default: EXPO_PUBLIC_INVITE_HOST)
+# EXPO_PUBLIC_TURNSTILE_HOSTNAME=musapp-731e1.web.app
+
## CI/CD (secrets: NO van al bundle)
### GitHub Actions — repo secrets
@@ -39,7 +47,8 @@ EXPO_PUBLIC_INVITE_HOST=musapp-731e1.web.app
### EAS Build (cloud/CI)
# Crea las mismas variables en EAS (Environment = production/preview/...) para que el build release arranque:
-# eas env:create production --name EXPO_PUBLIC_SUPABASE_URL --value "https://xxx.supabase.co" --type string --visibility plain
+# eas env:create production --name EXPO_PUBLIC_SUPABASE_URL --value "https://xxx.supabase.co" --type string --visibility plaintext
# eas env:create production --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value "..." --type string --visibility secret
-# eas env:create production --name EXPO_PUBLIC_SENTRY_DSN --value "https://..." --type string --visibility plain
-# eas env:create production --name EXPO_PUBLIC_POSTHOG_API_KEY --value "phc_..." --type string --visibility secret
\ No newline at end of file
+# eas env:create production --name EXPO_PUBLIC_SENTRY_DSN --value "https://..." --type string --visibility plaintext
+# eas env:create production --name EXPO_PUBLIC_POSTHOG_API_KEY --value "phc_..." --type string --visibility secret
+# eas env:create production --name EXPO_PUBLIC_TURNSTILE_SITE_KEY --value "0x4AAAAA..." --type string --visibility plaintext
\ No newline at end of file
diff --git a/README.md b/README.md
index 4dd5da8..788b867 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ La aplicación sigue una **arquitectura cliente-servidor** con separación clara
│
┌────────────────────────────▼────────────────────────────────┐
│ SERVICIOS EXTERNOS E INFRAESTRUCTURA │
-│ Sentry · PostHog · Expo EAS · GitHub Actions · Push (FCM/APNs) │
+│ Sentry · PostHog · Expo EAS · GitHub Actions · Push · Turnstile │
└─────────────────────────────────────────────────────────────┘
```
@@ -149,6 +149,7 @@ La aplicación sigue una **arquitectura cliente-servidor** con separación clara
| 🛡️ **Sentry** | Monitorización de errores y rendimiento |
| 📊 **PostHog** | Analítica de producto |
| 🔔 **Expo Push Notifications** | Notificaciones en Android (FCM) e iOS (APNs) |
+| 🧩 **Cloudflare Turnstile** | CAPTCHA en login, registro y recuperación de contraseña |
| 🌿 **GitFlow** | Estrategia de ramas: `main`, `develop`, `feature/*`, `release/*` |
---
@@ -160,12 +161,15 @@ La aplicación sigue una **arquitectura cliente-servidor** con separación clara
- Inicio de sesión con **Google**, **Apple ID** y **correo electrónico**
- Registro con aceptación de términos legales y política de privacidad
- Recuperación de contraseña y persistencia de sesión entre reinicios
+- **Cloudflare Turnstile** al enviar login, registro o recuperación (no en Google/Apple)
+- Contraseña con mayúscula, minúscula, dígito y símbolo; cambio desde el perfil con la contraseña actual
- **Eliminación de cuenta** conforme al RGPD, con anonimización del historial compartido (partidas, ligas y torneos)
- Cierre de sesión con confirmación explícita
### 👤 Perfil de usuario
- Datos personales: nombre, teléfono con validación internacional, localidad y foto de perfil
+- Cambio de contraseña en edición de perfil (exige la actual)
- Preferencias granulares de **notificaciones push** por tipo de evento
- Historial personal de partidas con indicadores de victoria y derrota
- Resumen de **estadísticas / ELO**, logros destacados y pantalla de stats detallada
@@ -236,6 +240,7 @@ La aplicación sigue una **arquitectura cliente-servidor** con separación clara
### 🔒 Seguridad y cumplimiento
- Autenticación OAuth2 con flujo PKCE y tokens de corta duración
+- **Turnstile** (Cloudflare) como desafío anti-bots en los formularios de email
- Políticas de acceso a datos por usuario y por rol en base de datos
- Protección de datos personales: teléfono visible solo entre participantes de la misma partida
- Cumplimiento RGPD: consentimiento, minimización de datos y derecho de supresión
diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md
index e417080..6874504 100644
--- a/REQUIREMENTS.md
+++ b/REQUIREMENTS.md
@@ -11,12 +11,14 @@ App móvil para jugadores de mus en España que permite encontrar contrincantes
### Decisiones clave
- **Marca y legal (may. 2026):** nombre comercial **jugaMUS** (`APP_DISPLAY_NAME`; nombre en launcher vía `app.json`). Deep link scheme `jugamus`. Términos y privacidad con texto estático y disclaimer «Texto legal definitivo pendiente de revisión jurídica.» hasta revisión legal.
-- **CI/CD (jun.–ago. 2026):** workflow reutilizable `quality.yml` (job `Quality`: Gitleaks, `expo-doctor`, lint, tests, cobertura ≥60% líneas en `src/{utils,lib,services,hooks}`). `eas.yml` en push a `main`: quality → tag `v{version}-{YYYYMMDD.HHmm}` → build Android/iOS → submit Play + TestFlight. `GITHUB_TOKEN` con mínimo privilegio: `contents: read` por defecto; `contents: write` solo en el job de etiquetado de release (`eas.yml`). Dependabot (npm + github-actions): version updates a **`develop`**, sin majors ni patch/minor del stack Expo/RN (upgrade de SDK con `npx expo upgrade`), grupos prod/dev + security, cooldown. Política de vulnerabilidades en `SECURITY.md`. Secrets GitHub: `EXPO_TOKEN`, `GOOGLE_PLAY_SERVICE_KEY_JSON`. EAS `production`: `GOOGLE_SERVICES_JSON`, `SENTRY_AUTH_TOKEN`, **`EXPO_PUBLIC_SUPABASE_URL`** / **`EXPO_PUBLIC_SUPABASE_ANON_KEY`**, **`EXPO_PUBLIC_INVITE_HOST`**. iOS: `ascAppId` `6775626292`.
+- **CI/CD (jun.–ago. 2026):** workflow reutilizable `quality.yml` (job `Quality`: Gitleaks, `expo-doctor`, lint, tests, cobertura ≥60% líneas en `src/{utils,lib,services,hooks}`). `eas.yml` en push a `main`: quality → tag `v{version}-{YYYYMMDD.HHmm}` → build Android/iOS → submit Play + TestFlight. `GITHUB_TOKEN` con mínimo privilegio: `contents: read` por defecto; `contents: write` solo en el job de etiquetado de release (`eas.yml`). Dependabot (npm + github-actions): version updates a **`develop`**, sin majors ni patch/minor del stack Expo/RN (upgrade de SDK con `npx expo upgrade`), grupos prod/dev + security, cooldown. Política de vulnerabilidades en `SECURITY.md`. Secrets GitHub: `EXPO_TOKEN`, `GOOGLE_PLAY_SERVICE_KEY_JSON`. EAS `production`: `GOOGLE_SERVICES_JSON`, `SENTRY_AUTH_TOKEN`, **`EXPO_PUBLIC_SUPABASE_URL`** / **`EXPO_PUBLIC_SUPABASE_ANON_KEY`**, **`EXPO_PUBLIC_INVITE_HOST`**, **`EXPO_PUBLIC_TURNSTILE_SITE_KEY`**. iOS: `ascAppId` `6775626292`.
- **Legal / Play (may. 2026):** URLs públicas de privacidad y eliminación de cuenta vía **GitHub Pages** (`docs/` en `main`, carpeta `/docs`). Contacto de soporte / seguridad: `japenago@gmail.com`. Package Android: `com.javiwacho.musapp`; slug EAS `musapp`.
- **Partidas (may. 2026):** el creador puede cancelar partidas en `planned` e `in_progress` desde la ficha (no hace falta ser participante). En web, las confirmaciones destructivas (cancelar, abandonar, aprobar resultado) usan **modales** en lugar de `Alert.alert`, que no es fiable en Expo Web. **Empezar partida (jul. 2026):** el creador puede pasar una partida `planned` a `in_progress` manualmente (sin modal de confirmación; sí aviso si la plantilla está incompleta); se fija `start_at` al instante actual.
- **Plantilla mixta (may. 2026):** en crear/editar se pueden añadir compañeros/rivales **por nombre** además de cuentas registradas; las plazas (UI, explore y cron) cuentan texto + confirmados (máx. 2 por equipo). El creador puede registrar marcador **sin validación rival** solo si no hay otros participantes con cuenta y la partida está **`in_progress`** (`record_match_result_direct`). Tras aprobar un resultado rival, un trigger en BD confirma el resultado y finaliza la partida (`018`).
- **Eliminación de cuenta (may.–ago. 2026):** derecho de supresión RGPD vía Edge Function `delete-account`. Se borran auth, perfil, avatar y datos personales (reportes, cola de notificaciones). El **historial compartido se anonimiza**, no se elimina: referencias de partidas, **ligas y torneos** (creador, parejas, retos, grants) pasan al perfil interno **Usuario eliminado** (sentinel) o a texto «Usuario eliminado» (mig. `023`–`025`, `105`). CORS de la función: orígenes de producción + loopback local (`localhost` / `127.0.0.1`) para desarrollo web.
-- **Recuperación de contraseña (jul. 2026):** el email de reset redirige a `jugamus://auth/update-password` (debe estar en Redirect URLs de Supabase). Pantalla dedicada para nueva contraseña; errores visibles en web (sin depender de `Alert.alert`); tras éxito, cierre de sesión y CTA a login. No reutilizar la misma contraseña (`same_password` → 422).
+- **Recuperación de contraseña (jul. 2026):** el email de reset redirige a `jugamus://auth/update-password` (debe estar en Redirect URLs de Supabase). Pantalla dedicada para nueva contraseña; errores visibles en web (sin depender de `Alert.alert`); tras éxito, cierre de sesión y CTA a login. No reutilizar la misma contraseña (`same_password` → 422). La sesión de recovery **no** exige contraseña actual.
+- **Política de contraseña (ago. 2026):** mínimo 8 caracteres con mayúscula, minúscula, dígito y símbolo (Auth dashboard + `authPasswordSchema` / `AUTH_PASSWORD_HINT`). Cambio de contraseña en Editar perfil exige la actual (`current_password`) y mantiene la sesión.
+- **CAPTCHA Turnstile (ago. 2026):** desafío al pulsar enviar en login, registro y recuperación (no OAuth). Site key `EXPO_PUBLIC_TURNSTILE_SITE_KEY` (EAS `production`); secret y CAPTCHA activos en Supabase Auth. Expo Go: hostname `localhost`. Release nativo: `https://musapp-731e1.web.app/turnstile.html` (Firebase Hosting + rebuild con `react-native-webview`).
- **Notificaciones en perfil (may./jul./ago. 2026):** preferencias **push** («Todas») y por **evento** en perfil; sin notificaciones por correo; enlaces legales (términos, privacidad) en la misma pantalla. Eventos: unión; partida/torneo inicio, edición y cancelación (separados); resultado pendiente de validar; resultado pendiente de enviar (aviso ~5 h en curso); recordatorios 24 h y/o 2 h antes (chips multi-selección); **solicitudes de amistad** e **invitaciones a partidas** (`notify_on_friend_request` / `notify_on_match_invitation`, mig. `110`). `enqueue_notification` y `process-notifications` respetan `notify_push` + `notify_on_*` (migraciones `077`–`079`, `110`).
- **Branding (may. 2026):** icono y splash con diseño minimalista de baraja española (basto); color de fondo `#1a5f4a` en splash e icono adaptativo Android.
- **UI Ultra Limpio (may. 2026):** rediseño visual con tokens en `src/theme/` (fondo blanco, verde `#1A5F4A`, tipografía DM Sans). Listas principales (Mis partidas, Descubrir) con filas y punto de estado; previews con `ciudad · lugar`; cabecera Mis partidas sin contador de activas; FAB speed-dial encima de la tab bar; tab bar activa en verde brand.
@@ -40,7 +42,7 @@ App móvil para jugadores de mus en España que permite encontrar contrincantes
- **PostHog producto (jul./ago. 2026):** eventos `user_signed_up`, `match_created`, `match_joined`, `match_completed` (este último solo al pasar a `finished`, idempotente por `match_id`); `friend_request_sent`, `friend_request_accepted` (auto-aceptación), `match_invite_sent`, `match_invite_accepted` (v1.8). KPIs de panel deben usar estos eventos / `Application Opened`, no `$pageview`.
- **Torneos — cuadro interactivo (jul. 2026):** cada pareja en tarjeta propia; tap en pareja para registrar resultado y avanzar (modal rápido o ficha de partido). Etiquetas de ronda (cuartos, semifinal, final). Confirmación destructiva al organizar cuadro. Al cancelar torneo se cancelan **todos** los partidos del cuadro (`082`). Fix auto-cancel al poblar la final (`081`). Notificación «Validar resultado» solo si el resultado queda `pending_validation` (`083`).
- **Amigos e invitaciones a partidas (ago. 2026, v1.8):** solicitudes de amistad con mensaje opcional (máx. 200) desde perfil ajeno o búsqueda por nombre; cooldown tras rechazo reciente; lista de amigos/solicitudes en perfil propio. Invitar amigos a pareja/rival al crear o editar equipo (solo partida standalone `planned`/`in_progress` del creador); pendientes ocupan plaza; compartir vía modal en ficha tras crear. Mis Partidas: Invitaciones; ficha: banner Aceptar/Rechazar. Rechazar en partida iniciada/finalizada (o con resultado confirmado) cancela y voidea resultado; rivales invitados fuerzan `pending_validation`. Migraciones `108`–`116`.
-- **Versión app (ago. 2026):** **1.8.0** (`app.json`, `package.json`). Incluye hotfix de calidad v1.7.1 (characterization tests, umbrales de cobertura ≥60%, proceso TDD en `docs/testing.md`).
+- **Versión app (ago. 2026):** **1.8.1** (`app.json`, `package.json`). Incluye v1.8.0 (amigos/invites) y este hotfix: Turnstile al enviar login/registro/recuperación, política de contraseña (mayúscula, minúscula, dígito y símbolo), cambio de contraseña en perfil, mig. `118`/`119`.
- **Security hardening ligas/stats (ago. 2026, mig. `106`):** `process_league_lifecycle` solo vía pg_cron (REVOKE a PUBLIC/authenticated). `enqueue_player_stats_recompute` no ejecutable por clientes. `get_player_stats` recalcula ELO/agregados solo para el propio usuario o admin; el resto lee stats cacheadas (mitiga amplificación cross-user).
- **UI responsive (jul. 2026):** helpers `useResponsiveLayout` / `ScrollableModalBody` para escalado tipográfico y modales con scroll seguro en pantallas pequeñas.
- **Crear partida — UX (jul.–ago. 2026):** título, ciudad y lugar **opcionales** al crear (defaults: «Partida», «Ciudad por definir», «Lugar por definir» si vacíos). Sin toggle «Lugar por definir» en creación. Fecha/hora por defecto **+10 min** respecto a ahora. Botón ✕ cierra a **Mis partidas** (no `back` a Descubrir). Sin autofocus/teclado al abrir el formulario. Etiquetas de campo en negrita; sin asteriscos ni «(opcional)» en labels de crear/editar. **Empezar partida:** sin modal de confirmación (sí aviso si plantilla incompleta). Edición de partida conserva validación anterior. Aviso si plantilla incompleta (auto-cancel al llegar `start_at`). **Partida ya jugada (ago. 2026):** si `start_at` es anterior a ahora, el formulario muestra el marcador al final (casillas vacías; nombres de equipo derivados como en ficha); exige plantilla completa (nombres o amigos invitados); al crear registra resultado (directo o `pending_validation` si hay rivales invitados) y abre la partida. Orden de pareja/equipo en formularios y tarjetas: **integrantes primero, nombre después**. **Invitar amigos (v1.8):** selector de amigos como compañero/rival al crear; pestaña «Añadir amigo» en editar pareja.
@@ -99,9 +101,9 @@ Solo dos roles en el MVP:
- Login con Google
- Login con Apple ID (obligatorio por requisitos de App Store)
-- Login con email y contraseña
+- Login con email y contraseña (Turnstile al enviar; complejidad: mayúscula, minúscula, dígito y símbolo)
- Registro con aceptación de términos y política de privacidad
-- Recuperación de contraseña para usuarios de email (pantalla `auth/update-password` tras el enlace; login con la nueva contraseña)
+- Recuperación de contraseña para usuarios de email (pantalla `auth/update-password` tras el enlace; login con la nueva contraseña; Turnstile al enviar el email)
- Gestión de sesiones con JWT y refresh tokens (gestionado por Supabase Auth)
- Estado de sesión persistente entre cierres de la app
- Eliminación de cuenta (RGPD): borrado de identidad + anonimización del historial en partidas, ligas y torneos
@@ -109,6 +111,7 @@ Solo dos roles en el MVP:
### F2 - Perfil de usuario (Fase 1)
- Nombre a mostrar (obligatorio)
+- Cambio de contraseña en edición de perfil (contraseña actual + nueva que cumple la política)
- Teléfono (obligatorio, validación formato E.164; en la app: selector de prefijo por país + validación genérica ITU-T, ej. `+34612345678` u otros países del listado)
- Localidad/pueblo (opcional, informativo)
- Foto de perfil (opcional, comprimida automáticamente a ≤ 500KB)
@@ -569,6 +572,7 @@ Supabase (PostgreSQL + Auth + Storage)
- TLS extremo a extremo
- JWT con expiración corta + refresh tokens
- Rate limiting por IP y usuario (Supabase + Edge Functions)
+- **Cloudflare Turnstile** en login, registro y recuperación de contraseña (site key en el cliente; secret en Auth)
- Protección CSRF/XSS/SSRF, validación exhaustiva de inputs
- Row Level Security (RLS) de PostgreSQL para aislar datos por usuario
- **Hardening (may. 2026, migraciones 038–047):**
@@ -633,6 +637,9 @@ Supabase (PostgreSQL + Auth + Storage)
- CA_AUTH2: Un usuario puede autenticarse con Google
- CA_AUTH3: Un usuario puede autenticarse con Apple ID
- CA_AUTH4: Un usuario puede recuperar su contraseña por email, fijar una nueva en `auth/update-password` e iniciar sesión después
+- CA_AUTH4b: Login, registro y «olvidé contraseña» piden Turnstile al pulsar enviar (no al abrir la pantalla; no aplica a Google/Apple)
+- CA_AUTH4c: El registro y el cambio de contraseña rechazan secretos sin mayúscula, minúscula, dígito y símbolo
+- CA_AUTH4d: En Editar perfil, cambiar la contraseña exige la actual y no cierra la sesión
- CA_AUTH5: La sesión persiste entre cierres de la app (refresh token); tras ≥6 h en segundo plano, al volver a primer plano se cierra la sesión con aviso
- CA_AUTH6: Un usuario con `status: suspended` no puede iniciar sesión
- CA_AUTH7: Un usuario puede eliminar su cuenta; se borra la identidad y se anonimiza su huella en partidas, ligas y torneos (perfil sentinel «Usuario eliminado»)
diff --git a/SECURITY.md b/SECURITY.md
index cbefb90..f79f9ed 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -30,7 +30,7 @@ Include as much detail as you can:
In scope:
-- Authentication / session issues (including OAuth)
+- Authentication / session issues (including OAuth and CAPTCHA/Turnstile bypass)
- Unauthorized data access (RLS / API / Edge Functions)
- Injection, XSS, or similar client/server flaws
- Secrets exposure in the repo or client builds
diff --git a/TASKS.md b/TASKS.md
index 112cdce..f66a28a 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -1,6 +1,6 @@
# Tareas - jugaMUS
-> Actualizado: 12/08/2026 (merge main hotfix v1.7.1; timeout sesión 6 h; invites `117`)
+> Actualizado: 13/08/2026 (hotfix v1.8.1 cerrado: operación Turnstile lista)
> Metodología: Kanban personal. Actualizar al inicio y al final de cada sesión de trabajo.
---
@@ -27,6 +27,7 @@
| Release 1.7 | Completada | Contactos, stats/ELO/badges, ligas, CI permissions, deps; v1.7.0 |
| Hotfix tests TDD | Completada | Characterization tests + coverage gates + docs; v1.7.1 |
| Release 1.8 | Completada | Amigos, invitaciones a partidas, prefs notif. friend/invite; v1.8.0 |
+| Hotfix auth/Turnstile | Completada | CAPTCHA Turnstile, política de contraseña, cambio en perfil; ops lista; v1.8.1 |
---
@@ -65,14 +66,19 @@
- Migraciones `006`/`007` en repo: RLS sin recursión en `match_participants` + backfill `profiles` desde `auth.users` (evitar 500 tras login).
- [x] Pantalla de login con email/contraseña
- Mensajes claros si Supabase devuelve 429 (rate limit por IP en plan gratuito).
+ - Cloudflare Turnstile al pulsar **Entrar** (no al abrir la pantalla). Site key `EXPO_PUBLIC_TURNSTILE_SITE_KEY`; secret solo en Supabase Auth → Bot and Abuse Protection.
- [x] Pantalla de registro con aceptación de términos y política de privacidad
- Textos estáticos en `src/app/(auth)/terms.tsx` y `privacy.tsx` con disclaimer jurídico; **revisión legal pendiente** antes de release.
- Confirmación por email configurada según entorno (desactivada en dev; producción según política del proyecto).
+ - Contraseña: mínimo 8 + mayúscula + minúscula + dígito + símbolo (`AUTH_PASSWORD_HINT`; alineado con Auth dashboard).
+ - Turnstile al pulsar **Registrarme**.
- [x] Pantalla de recuperación de contraseña
- Email → redirect `jugamus://auth/update-password` (`getPasswordResetRedirectUrl`).
- Pantalla `src/app/auth/update-password.tsx`: nueva contraseña, errores inline (web; `Alert` no fiable), ✕ → login, éxito → CTA «Ir al login».
- Tras guardar: `updateUser` + `signOut`; gate `passwordRecoveryPending` se limpia en login normal (evita redirigir otra vez a update-password).
+ - Recovery **no** pide contraseña actual (sesión de recovery en GoTrue).
- Supabase Redirect URLs: añadir `jugamus://auth/update-password` (además de `auth/callback`).
+ - Turnstile al pulsar **Enviar enlace**.
- [x] Login con Google (OAuth via Supabase)
- Requiere MANUAL-1 y MANUAL-2 del plan (Google Cloud + Supabase provider).
- Redirects típicos: `exp://**` (Expo Go), `jugamus://auth/callback`, y en web el `http://localhost:PUERTO/` del `expo start --web`.
@@ -99,6 +105,7 @@
- [x] Pantalla de perfil (vista propia)
- [x] Pantalla de edición de perfil
+ - Cambio de contraseña con contraseña actual (`updateUser` + `current_password`; la sesión se mantiene).
- [x] Campo de teléfono con validación E.164 (selector de país + número; validación genérica `+` y 7–15 dígitos)
- [x] Subida de foto de perfil a Supabase Storage (compresión ≤ 500 KB; bucket `avatars` migración `008`; subida sin `Blob.arrayBuffer` en iOS/Hermes)
- [x] Preferencias de notificación (push; sin email, migración `057`)
@@ -255,7 +262,7 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre
- [x] Configurar EAS Submit para publicación automática en Google Play
- Workflow `.github/workflows/eas.yml`: push a `main` → `eas build` → `eas submit` Android. Secrets GitHub: `EXPO_TOKEN`, `GOOGLE_PLAY_SERVICE_KEY_JSON` (clave JSON de **cuenta de servicio** en Google Cloud: `type`, `private_key`, `client_email` — **no** `google-services.json` de Firebase). Variables EAS `production`: `GOOGLE_SERVICES_JSON`, `SENTRY_AUTH_TOKEN`.
- PRs mergeados en `develop`: slug EAS `musapp`, `appVersionSource: remote`, `app.config.js` + `GOOGLE_SERVICES_JSON`, validación JSON Play submit, `npm ci` antes de `eas submit`.
-- [x] **Variables EAS `production` obligatorias para el bundle:** `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SUPABASE_ANON_KEY` (sin ellas la app en release queda en pantalla en blanco). Opcional: `EXPO_PUBLIC_SENTRY_DSN`, `EXPO_PUBLIC_POSTHOG_API_KEY`.
+- [x] **Variables EAS `production` obligatorias para el bundle:** `EXPO_PUBLIC_SUPABASE_URL`, `EXPO_PUBLIC_SUPABASE_ANON_KEY` (sin ellas la app en release queda en pantalla en blanco), **`EXPO_PUBLIC_TURNSTILE_SITE_KEY`** (CAPTCHA Auth activo; secret en Supabase). Opcional: `EXPO_PUBLIC_SENTRY_DSN`, `EXPO_PUBLIC_POSTHOG_API_KEY`.
- Confirmar con `eas env:list --environment production`; **nuevo build** tras añadirlas.
- [x] Configurar EAS Submit para publicación automática en App Store
- PR #59 mergeado en `develop`: jobs `build-ios` + `submit-ios`; submit iOS vía ASC API key en EAS (`EXPO_TOKEN`).
@@ -567,6 +574,18 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre
- [x] Documentación alineada (`REQUIREMENTS.md`, `TASKS.md`, `README.md`)
- [x] Merge `main` (hotfix v1.7.1) en `develop` para desbloquear PR #158
+### Hotfix v1.8.1 — Turnstile y contraseñas (ago. 2026)
+
+- [x] Cloudflare Turnstile en login/registro/recuperación (modal al enviar; no OAuth)
+ - Site key `EXPO_PUBLIC_TURNSTILE_SITE_KEY`; secret solo en Supabase Auth → Bot and Abuse Protection.
+ - Expo Go: hostname del widget **`localhost`**. Release: **`musapp-731e1.web.app`** + página `invite-hosting/public/turnstile.html`.
+- [x] Operación Turnstile: site key en EAS `production`, hostnames Cloudflare (`localhost`, `musapp-731e1.web.app`), `turnstile.html` en Firebase Hosting, CAPTCHA activo en Supabase Auth, rebuild nativo con `react-native-webview`
+- [x] Política de contraseña: min 8 + mayúscula + minúscula + dígito + símbolo
+- [x] Cambio de contraseña en Editar perfil (exige actual; recovery no)
+- [x] Security RLS/RPC lockdown mig. `118`; `search_path` helpers de torneo mig. `119`
+- [x] Versión app → **1.8.1** (`app.json`, `package.json`)
+- [x] README: Turnstile en autenticación, stack y seguridad
+
---
## UI — Rediseño Ultra Limpio (may. 2026)
@@ -682,3 +701,4 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre
- `process_league_lifecycle`: REVOKE PUBLIC/authenticated + cron `match-state-transitions` (igual que torneos).
- Fix typo REVOKE en migración `104` (`enqueue_player_stats_recompute`) + REVOKE defensivo en `106`.
- `get_player_stats`: refresh-on-read (ELO + agregados) solo para `auth.uid()` o admin; resto lee cache.
+- [x] **Security RLS/RPC (ago. 2026, mig. `118`/`119`):** RLS en cola de stats; SELECT de `match_invitations` y `player_stats` acotado; REVOKE de RPCs de lifecycle/`enqueue_notification`; `search_path` en helpers de torneo. HaveIBeenPwned no disponible en plan Free.
diff --git a/app.json b/app.json
index ce6607d..2b0fb77 100644
--- a/app.json
+++ b/app.json
@@ -3,7 +3,7 @@
"name": "jugaMUS",
"slug": "musapp",
"scheme": "jugamus",
- "version": "1.8.0",
+ "version": "1.8.1",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
diff --git a/invite-hosting/README.md b/invite-hosting/README.md
index ce0c234..184f85b 100644
--- a/invite-hosting/README.md
+++ b/invite-hosting/README.md
@@ -1,9 +1,10 @@
# Invite Hosting (Firebase)
-Static HTTPS endpoints for WhatsApp invite links:
+Static HTTPS endpoints for WhatsApp invite links and the Turnstile challenge page:
- `https://musapp-731e1.web.app/m/{matchId}`
- `https://musapp-731e1.web.app/t/{tournamentId}`
+- `https://musapp-731e1.web.app/turnstile.html` (native captcha WebView in release builds)
With the app installed and App/Universal Links verified, the OS opens jugaMUS. Otherwise `redirect.html` sends the browser to Play Store / App Store.
@@ -14,7 +15,7 @@ With the app installed and App/Universal Links verified, the OS opens jugaMUS. O
3. From this folder: `firebase deploy --only hosting`.
4. Set app env:
- Local `.env.local`: `EXPO_PUBLIC_INVITE_HOST=musapp-731e1.web.app`
- - EAS: `eas env:create --name EXPO_PUBLIC_INVITE_HOST --value "musapp-731e1.web.app" --environment production --visibility plain`
+ - EAS: `eas env:create --name EXPO_PUBLIC_INVITE_HOST --value "musapp-731e1.web.app" --environment production --visibility plaintext`
5. Ship a new native build (EAS) so associated domains / intent filters are in the binary.
## Verify
diff --git a/invite-hosting/public/turnstile.html b/invite-hosting/public/turnstile.html
new file mode 100644
index 0000000..0057795
--- /dev/null
+++ b/invite-hosting/public/turnstile.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+ jugaMUS
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 74823cc..264e394 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,17 @@
{
"name": "jugamus",
- "version": "1.8.0",
+ "version": "1.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jugamus",
- "version": "1.8.0",
+ "version": "1.8.1",
"dependencies": {
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/vector-icons": "^15.1.1",
"@hookform/resolvers": "^5.7.1",
+ "@marsidev/react-turnstile": "^1.6.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/slider": "5.0.1",
@@ -50,6 +51,7 @@
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "^0.21.0",
+ "react-native-webview": "13.15.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
@@ -4161,6 +4163,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@marsidev/react-turnstile": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.0.tgz",
+ "integrity": "sha512-T2Um71ZdBgQBiyS01xgRMvDWk+mEsrLtXLFVhOC917OVEO584wbPOWj1sw35RaM17+q9QoADgLVcH60xr9NRqQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^17.0.2 || ^18.0.0 || ^19.0",
+ "react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
+ }
+ },
"node_modules/@posthog/core": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz",
@@ -16638,6 +16650,32 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
+ "node_modules/react-native-webview": {
+ "version": "13.15.0",
+ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
+ "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "invariant": "2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-webview/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
diff --git a/package.json b/package.json
index 7ce3316..727d023 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jugamus",
- "version": "1.8.0",
+ "version": "1.8.1",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -20,6 +20,7 @@
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/vector-icons": "^15.1.1",
"@hookform/resolvers": "^5.7.1",
+ "@marsidev/react-turnstile": "^1.6.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/slider": "5.0.1",
@@ -59,6 +60,7 @@
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "^0.21.0",
+ "react-native-webview": "13.15.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
diff --git a/src/app/(auth)/forgot-password.tsx b/src/app/(auth)/forgot-password.tsx
index 84b00dd..569f9b7 100644
--- a/src/app/(auth)/forgot-password.tsx
+++ b/src/app/(auth)/forgot-password.tsx
@@ -12,9 +12,11 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { Link } from 'expo-router'
+import { TurnstileChallengeModal } from '@/components/auth/TurnstileChallengeModal'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { useAuthStore } from '@/hooks/useAuth'
+import { useTurnstileCaptcha } from '@/hooks/useTurnstileCaptcha'
import { forgotPasswordSchema, type ForgotPasswordFormValues } from '@/utils/authSchemas'
import { Colors } from '@/theme/colors'
import { useResponsiveLayout } from '@/theme/responsive'
@@ -23,6 +25,7 @@ import { Fonts } from '@/theme/typography'
export default function ForgotPasswordScreen() {
const { authTopPadding, font } = useResponsiveLayout()
const resetPassword = useAuthStore((s) => s.resetPassword)
+ const captcha = useTurnstileCaptcha()
const [sent, setSent] = useState(false)
const [submitting, setSubmitting] = useState(false)
@@ -36,9 +39,15 @@ export default function ForgotPasswordScreen() {
})
const onSubmit = handleSubmit(async (values) => {
+ const captchaResult = await captcha.solve()
+ if (captchaResult.cancelled) return
+ if (captchaResult.error) {
+ Alert.alert('Recuperación', captchaResult.error)
+ return
+ }
setSubmitting(true)
try {
- const { error } = await resetPassword(values.email)
+ const { error } = await resetPassword(values.email, captchaResult.token)
if (error) {
Alert.alert('Recuperación', error.message)
return
@@ -95,7 +104,7 @@ export default function ForgotPasswordScreen() {
) : null}
@@ -104,6 +113,12 @@ export default function ForgotPasswordScreen() {
Volver al inicio de sesión
+
)
}
diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx
index a6315c8..e8970fc 100644
--- a/src/app/(auth)/login.tsx
+++ b/src/app/(auth)/login.tsx
@@ -14,9 +14,11 @@ import { Link, useRouter, type Href } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { APP_DISPLAY_NAME } from '@/constants/app'
+import { TurnstileChallengeModal } from '@/components/auth/TurnstileChallengeModal'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { useAuthStore } from '@/hooks/useAuth'
+import { useTurnstileCaptcha } from '@/hooks/useTurnstileCaptcha'
import { supabase } from '@/lib/supabase'
import { loginSchema, type LoginFormValues } from '@/utils/authSchemas'
import { Colors } from '@/theme/colors'
@@ -33,6 +35,7 @@ export default function LoginScreen() {
const lastAuthMessage = useAuthStore((s) => s.lastAuthMessage)
const clearLastAuthMessage = useAuthStore((s) => s.clearLastAuthMessage)
const [formError, setFormError] = useState(null)
+ const captcha = useTurnstileCaptcha()
const {
control,
@@ -51,7 +54,13 @@ export default function LoginScreen() {
const onSubmit = handleSubmit(async (values) => {
setFormError(null)
- const { error } = await signInWithPassword(values.email, values.password)
+ const captchaResult = await captcha.solve()
+ if (captchaResult.cancelled) return
+ if (captchaResult.error) {
+ setFormError(captchaResult.error)
+ return
+ }
+ const { error } = await signInWithPassword(values.email, values.password, captchaResult.token)
if (error) {
setFormError(error.message)
}
@@ -155,7 +164,12 @@ export default function LoginScreen() {
¿Has olvidado la contraseña?
-
+
@@ -186,6 +200,12 @@ export default function LoginScreen() {
+
)
}
diff --git a/src/app/(auth)/register.tsx b/src/app/(auth)/register.tsx
index 59fc5c9..273705b 100644
--- a/src/app/(auth)/register.tsx
+++ b/src/app/(auth)/register.tsx
@@ -13,10 +13,12 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { Link, useRouter } from 'expo-router'
+import { TurnstileChallengeModal } from '@/components/auth/TurnstileChallengeModal'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { useAuthStore } from '@/hooks/useAuth'
-import { registerSchema, type RegisterFormValues } from '@/utils/authSchemas'
+import { useTurnstileCaptcha } from '@/hooks/useTurnstileCaptcha'
+import { AUTH_PASSWORD_HINT, registerSchema, type RegisterFormValues } from '@/utils/authSchemas'
import { Colors } from '@/theme/colors'
import { useResponsiveLayout } from '@/theme/responsive'
import { Fonts } from '@/theme/typography'
@@ -25,6 +27,7 @@ export default function RegisterScreen() {
const router = useRouter()
const { authTopPadding, font } = useResponsiveLayout()
const signUp = useAuthStore((s) => s.signUp)
+ const captcha = useTurnstileCaptcha()
const [submitting, setSubmitting] = useState(false)
const {
@@ -43,12 +46,19 @@ export default function RegisterScreen() {
})
const onSubmit = handleSubmit(async (values) => {
+ const captchaResult = await captcha.solve()
+ if (captchaResult.cancelled) return
+ if (captchaResult.error) {
+ Alert.alert('Registro', captchaResult.error)
+ return
+ }
setSubmitting(true)
try {
const { error } = await signUp({
email: values.email,
password: values.password,
displayName: values.displayName,
+ captchaToken: captchaResult.token,
})
if (error) {
Alert.alert('Registro', error.message)
@@ -107,6 +117,8 @@ export default function RegisterScreen() {
)}
/>
+ {AUTH_PASSWORD_HINT}
+
-
+
¿Ya tienes cuenta?
@@ -179,6 +196,12 @@ export default function RegisterScreen() {
+
)
}
@@ -200,6 +223,12 @@ const styles = StyleSheet.create({
color: Colors.textSecondary,
marginBottom: 24,
},
+ passwordHint: {
+ fontSize: 13,
+ color: Colors.textSecondary,
+ marginBottom: 12,
+ lineHeight: 18,
+ },
btn: { marginTop: 8 },
termsBlock: {
marginTop: 8,
diff --git a/src/app/(tabs)/matches/[id].tsx b/src/app/(tabs)/matches/[id].tsx
index 509c8f0..bd87d2a 100644
--- a/src/app/(tabs)/matches/[id].tsx
+++ b/src/app/(tabs)/matches/[id].tsx
@@ -808,9 +808,9 @@ export default function MatchDetailScreen() {
const slotsA = freeTeamSlots(match, match.participants, TEAM.A)
const slotsB = freeTeamSlots(match, match.participants, TEAM.B)
- const pendingInvites = (matchInvitations ?? []).filter(
- (inv) => inv.status === 'pending' && match.status !== MATCH_STATUS.CANCELLED
- )
+ const pendingInvites = isCancelled
+ ? []
+ : (matchInvitations ?? []).filter((inv) => inv.status === 'pending')
const pendingInvitesA = pendingInvites.filter((inv) => inv.team === TEAM.A)
const pendingInvitesB = pendingInvites.filter((inv) => inv.team === TEAM.B)
const displaySlotsA = Math.max(0, slotsA - pendingInvitesA.length)
@@ -925,13 +925,11 @@ export default function MatchDetailScreen() {
const matchId = Array.isArray(id) ? id[0] : id
// list_my_match_invitations only returns pending invites; `match_status` is match status.
- const myPendingInvitation =
- (myInvitations ?? []).find(
- (inv) =>
- inv.match_id === matchId &&
- inv.match_status !== MATCH_STATUS.CANCELLED &&
- match.status !== MATCH_STATUS.CANCELLED
- ) ?? null
+ const myPendingInvitation = isCancelled
+ ? null
+ : ((myInvitations ?? []).find(
+ (inv) => inv.match_id === matchId && inv.match_status !== MATCH_STATUS.CANCELLED
+ ) ?? null)
const handleAcceptInvitation = async () => {
if (!myPendingInvitation) return
@@ -1619,6 +1617,10 @@ export default function MatchDetailScreen() {
: undefined
}
team={editingTeam ?? undefined}
+ occupiedUserIds={[
+ ...activeParticipants.map((p) => p.user_id),
+ ...pendingInvites.map((inv) => inv.invitee_id),
+ ]}
freeSlots={
editingTeam === TEAM.A ? displaySlotsA : editingTeam === TEAM.B ? displaySlotsB : 0
}
diff --git a/src/app/(tabs)/matches/create.tsx b/src/app/(tabs)/matches/create.tsx
index 1816e92..fe690ae 100644
--- a/src/app/(tabs)/matches/create.tsx
+++ b/src/app/(tabs)/matches/create.tsx
@@ -350,7 +350,13 @@ export default function CreateMatchScreen() {
...invitesB.slice(0, inviteCapacityB).map((fid) => ({ fid, team: TEAM.B })),
]
+ if (!sessionUserId) {
+ showAlert('Error', 'Debes iniciar sesión para crear una partida.')
+ return
+ }
+
setIsSubmitting(true)
+ let createdMatchId: string | null = null
try {
const match = await createMatch.mutateAsync({
data: {
@@ -372,6 +378,7 @@ export default function CreateMatchScreen() {
},
password: values.visibility === MATCH_VISIBILITY.PRIVATE ? values.password : undefined,
})
+ createdMatchId = match.id
if (!isPastResultMode && hasIncompleteMatchRoster(effectiveRoster)) {
await acknowledgeAlert(
AUTO_CANCEL_INCOMPLETE_ROSTER_ALERT.title,
@@ -405,7 +412,7 @@ export default function CreateMatchScreen() {
if (hasRivalInvites && rivalInvitesOk) {
await submitResult.mutateAsync({
matchId: match.id,
- submittedByUserId: sessionUserId!,
+ submittedByUserId: sessionUserId,
submittedByTeam: TEAM.A,
teamAGames: pastResult.teamAGames,
teamBGames: pastResult.teamBGames,
@@ -445,7 +452,19 @@ export default function CreateMatchScreen() {
},
} as Href)
} catch (err) {
- Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo crear la partida')
+ const message = err instanceof Error ? err.message : 'Ha ocurrido un error'
+ if (createdMatchId) {
+ Alert.alert('Partida creada', `${message}. Puedes continuar desde la ficha de la partida.`)
+ router.replace({
+ pathname: '/(tabs)/matches/[id]',
+ params: { id: createdMatchId },
+ } as Href)
+ } else {
+ Alert.alert(
+ 'Error',
+ message === 'Ha ocurrido un error' ? 'No se pudo crear la partida' : message
+ )
+ }
} finally {
setIsSubmitting(false)
}
diff --git a/src/app/(tabs)/matches/index.tsx b/src/app/(tabs)/matches/index.tsx
index 17bfcc9..332a210 100644
--- a/src/app/(tabs)/matches/index.tsx
+++ b/src/app/(tabs)/matches/index.tsx
@@ -279,6 +279,7 @@ export default function MatchesScreen() {
const { data: invitations } = useMyMatchInvitations()
const respondInvitation = useRespondMatchInvitation()
const [isUserRefreshing, setIsUserRefreshing] = useState(false)
+ const [respondingInvitationId, setRespondingInvitationId] = useState(null)
const onUserRefresh = useCallback(async () => {
setIsUserRefreshing(true)
@@ -361,12 +362,14 @@ export default function MatchesScreen() {
return (
router.push(`/(tabs)/matches/${item.invitation.match_id}` as Href)}
- onAccept={() =>
+ onAccept={() => {
+ const invitationId = item.invitation.invitation_id
+ setRespondingInvitationId(invitationId)
void respondInvitation
.mutateAsync({
- invitationId: item.invitation.invitation_id,
+ invitationId,
accept: true,
matchId: item.invitation.match_id,
team: item.invitation.team,
@@ -378,11 +381,14 @@ export default function MatchesScreen() {
err instanceof Error ? err.message : 'Error'
)
})
- }
- onReject={() =>
+ .finally(() => setRespondingInvitationId(null))
+ }}
+ onReject={() => {
+ const invitationId = item.invitation.invitation_id
+ setRespondingInvitationId(invitationId)
void respondInvitation
.mutateAsync({
- invitationId: item.invitation.invitation_id,
+ invitationId,
accept: false,
matchId: item.invitation.match_id,
team: item.invitation.team,
@@ -393,7 +399,8 @@ export default function MatchesScreen() {
err instanceof Error ? err.message : 'Error'
)
})
- }
+ .finally(() => setRespondingInvitationId(null))
+ }}
/>
)
}
diff --git a/src/app/(tabs)/profile/[userId].tsx b/src/app/(tabs)/profile/[userId].tsx
index 4e0bc86..a91d255 100644
--- a/src/app/(tabs)/profile/[userId].tsx
+++ b/src/app/(tabs)/profile/[userId].tsx
@@ -268,16 +268,26 @@ function FriendActionButton({
disabled={busy}
accessibilityRole="button"
accessibilityLabel="Aceptar solicitud"
+ accessibilityState={{ disabled: busy, busy }}
style={({ pressed }) => [styles.friendBtn, pressed && styles.friendBtnPressed]}>
-
+ {busy ? (
+
+ ) : (
+
+ )}
[styles.friendBtn, pressed && styles.friendBtnPressed]}>
-
+ {busy ? (
+
+ ) : (
+
+ )}
)
@@ -289,8 +299,13 @@ function FriendActionButton({
disabled={busy}
accessibilityRole="button"
accessibilityLabel="Cancelar solicitud enviada"
+ accessibilityState={{ disabled: busy, busy }}
style={({ pressed }) => [styles.friendBtn, pressed && styles.friendBtnPressed]}>
-
+ {busy ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/src/app/(tabs)/profile/edit.tsx b/src/app/(tabs)/profile/edit.tsx
index 045895e..6041722 100644
--- a/src/app/(tabs)/profile/edit.tsx
+++ b/src/app/(tabs)/profile/edit.tsx
@@ -14,6 +14,11 @@ import { MunicipalityPicker } from '@/components/ui/MunicipalityPicker'
import { PhoneInput } from '@/components/ui/PhoneInput'
import { useAuthStore } from '@/hooks/useAuth'
import { useProfile, useUpdateProfile, useUploadAvatar } from '@/hooks/useProfile'
+import {
+ AUTH_PASSWORD_HINT,
+ changePasswordSchema,
+ type ChangePasswordFormValues,
+} from '@/utils/authSchemas'
import { phoneE164Schema } from '@/utils/validators'
import { Colors } from '@/theme/colors'
import { useResponsiveLayout } from '@/theme/responsive'
@@ -33,12 +38,16 @@ export default function EditProfileScreen() {
const insets = useSafeAreaInsets()
const { font, space } = useResponsiveLayout()
const sessionUserId = useAuthStore((s) => s.session?.user.id)
+ const updatePassword = useAuthStore((s) => s.updatePassword)
const { data: profile, isLoading } = useProfile(sessionUserId)
const updateProfile = useUpdateProfile()
const uploadAvatar = useUploadAvatar()
const [avatarUri, setAvatarUri] = useState(null)
const [pendingMimeType, setPendingMimeType] = useState(null)
+ const [passwordError, setPasswordError] = useState(null)
+ const [passwordSuccess, setPasswordSuccess] = useState(false)
+ const [changingPassword, setChangingPassword] = useState(false)
const {
control,
@@ -54,6 +63,20 @@ export default function EditProfileScreen() {
},
})
+ const {
+ control: passwordControl,
+ handleSubmit: handlePasswordSubmit,
+ reset: resetPassword,
+ formState: { errors: passwordErrors, isDirty: passwordDirty },
+ } = useForm({
+ resolver: zodResolver(changePasswordSchema),
+ defaultValues: {
+ currentPassword: '',
+ password: '',
+ confirmPassword: '',
+ },
+ })
+
// Populate form once profile loads
useEffect(() => {
if (profile) {
@@ -116,6 +139,23 @@ export default function EditProfileScreen() {
const isSaving = updateProfile.isPending || uploadAvatar.isPending
+ const onChangePassword = handlePasswordSubmit(async (values) => {
+ setPasswordError(null)
+ setPasswordSuccess(false)
+ setChangingPassword(true)
+ try {
+ const { error } = await updatePassword(values.password, values.currentPassword)
+ if (error) {
+ setPasswordError(error.message)
+ return
+ }
+ resetPassword()
+ setPasswordSuccess(true)
+ } finally {
+ setChangingPassword(false)
+ }
+ })
+
if (isLoading) {
return (
@@ -221,14 +261,100 @@ export default function EditProfileScreen() {
+
+ Cambiar contraseña
+ {AUTH_PASSWORD_HINT}
+
+ {passwordError ? (
+
+ {passwordError}
+
+ ) : null}
+ {passwordSuccess ? (
+
+ Contraseña actualizada
+
+ ) : null}
+
+ (
+ {
+ setPasswordError(null)
+ setPasswordSuccess(false)
+ onChange(text)
+ }}
+ error={passwordErrors.currentPassword?.message}
+ />
+ )}
+ />
+ (
+ {
+ setPasswordError(null)
+ setPasswordSuccess(false)
+ onChange(text)
+ }}
+ error={passwordErrors.password?.message}
+ />
+ )}
+ />
+ (
+ {
+ setPasswordError(null)
+ setPasswordSuccess(false)
+ onChange(text)
+ }}
+ error={passwordErrors.confirmPassword?.message}
+ />
+ )}
+ />
+
+
+
@@ -299,4 +425,47 @@ const styles = StyleSheet.create({
fields: {
gap: 8,
},
+ passwordSection: {
+ gap: 8,
+ marginTop: 8,
+ paddingTop: 8,
+ borderTopWidth: 1,
+ borderTopColor: Colors.border,
+ },
+ sectionTitle: {
+ fontFamily: Fonts.bold,
+ color: Colors.primary,
+ },
+ passwordHint: {
+ fontSize: 13,
+ color: Colors.textSecondary,
+ lineHeight: 18,
+ marginBottom: 4,
+ },
+ formError: {
+ backgroundColor: Colors.surface,
+ borderWidth: 1,
+ borderColor: Colors.danger,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ formErrorText: {
+ fontSize: 14,
+ fontFamily: Fonts.medium,
+ color: Colors.danger,
+ },
+ formSuccess: {
+ backgroundColor: Colors.surface,
+ borderWidth: 1,
+ borderColor: Colors.primary,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ formSuccessText: {
+ fontSize: 14,
+ fontFamily: Fonts.medium,
+ color: Colors.primary,
+ },
})
diff --git a/src/app/auth/update-password.tsx b/src/app/auth/update-password.tsx
index 8bae466..20c542f 100644
--- a/src/app/auth/update-password.tsx
+++ b/src/app/auth/update-password.tsx
@@ -20,7 +20,11 @@ import { APP_PASSWORD_UPDATE_PATH, APP_SCHEME } from '@/constants/app'
import { useAuthStore } from '@/hooks/useAuth'
import { completeOAuthSessionFromCallbackUrl, waitForAuthSession } from '@/lib/completeOAuthSession'
import { supabase } from '@/lib/supabase'
-import { updatePasswordSchema, type UpdatePasswordFormValues } from '@/utils/authSchemas'
+import {
+ AUTH_PASSWORD_HINT,
+ updatePasswordSchema,
+ type UpdatePasswordFormValues,
+} from '@/utils/authSchemas'
import { Colors } from '@/theme/colors'
import { useResponsiveLayout } from '@/theme/responsive'
import { Fonts } from '@/theme/typography'
@@ -239,7 +243,9 @@ export default function UpdatePasswordScreen() {
Nueva contraseña
- Elige una contraseña nueva para tu cuenta.
+
+ Elige una contraseña nueva para tu cuenta. {AUTH_PASSWORD_HINT}
+
{formError ? (
diff --git a/src/components/auth/TurnstileChallengeModal.tsx b/src/components/auth/TurnstileChallengeModal.tsx
new file mode 100644
index 0000000..0bf146b
--- /dev/null
+++ b/src/components/auth/TurnstileChallengeModal.tsx
@@ -0,0 +1,114 @@
+import { useState } from 'react'
+import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'
+
+import { TurnstileWidget } from '@/components/auth/TurnstileWidget'
+import { Button } from '@/components/ui/Button'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+export interface TurnstileChallengeModalProps {
+ visible: boolean
+ resetNonce: number
+ onSuccess: (token: string) => void
+ onCancel: () => void
+}
+
+export function TurnstileChallengeModal({
+ visible,
+ resetNonce,
+ onSuccess,
+ onCancel,
+}: TurnstileChallengeModalProps) {
+ return (
+
+ {visible ? (
+
+ ) : null}
+
+ )
+}
+
+function ChallengeBody({
+ onSuccess,
+ onCancel,
+}: {
+ onSuccess: (token: string) => void
+ onCancel: () => void
+}) {
+ const [error, setError] = useState(null)
+ const [retryNonce, setRetryNonce] = useState(0)
+
+ return (
+
+
+
+ Verificación de seguridad
+ Confirma que no eres un robot para continuar.
+ {!error ? (
+ {
+ if (!token) return
+ setError(null)
+ onSuccess(token)
+ }}
+ onError={(message) => setError(message)}
+ />
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {error ? (
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(17, 17, 17, 0.45)',
+ justifyContent: 'center',
+ paddingHorizontal: 24,
+ },
+ card: {
+ zIndex: 1,
+ elevation: 4,
+ backgroundColor: Colors.background,
+ borderRadius: 12,
+ paddingHorizontal: 20,
+ paddingTop: 20,
+ paddingBottom: 16,
+ },
+ title: {
+ fontFamily: Fonts.bold,
+ fontSize: 17,
+ color: Colors.textPrimary,
+ marginBottom: 8,
+ },
+ sub: {
+ fontSize: 15,
+ lineHeight: 22,
+ color: Colors.textSecondary,
+ marginBottom: 8,
+ },
+ error: {
+ fontSize: 14,
+ lineHeight: 20,
+ color: Colors.danger,
+ marginBottom: 12,
+ },
+ btn: { marginTop: 4 },
+})
diff --git a/src/components/auth/TurnstileWidget.native.tsx b/src/components/auth/TurnstileWidget.native.tsx
new file mode 100644
index 0000000..942ba3d
--- /dev/null
+++ b/src/components/auth/TurnstileWidget.native.tsx
@@ -0,0 +1,65 @@
+import { StyleSheet, View } from 'react-native'
+import { WebView, type WebViewMessageEvent } from 'react-native-webview'
+
+import type { TurnstileWidgetProps } from '@/components/auth/TurnstileWidget.types'
+import {
+ CAPTCHA_WIDGET_ERROR_MESSAGE,
+ getTurnstileSiteKey,
+ getTurnstileWebViewSource,
+ parseTurnstileWebViewMessage,
+} from '@/lib/turnstile'
+
+export type { TurnstileWidgetProps }
+
+export function TurnstileWidget({ onTokenChange, onError, resetNonce = 0 }: TurnstileWidgetProps) {
+ const siteKey = getTurnstileSiteKey()
+ if (!siteKey) return null
+
+ const onMessage = (event: WebViewMessageEvent) => {
+ const message = parseTurnstileWebViewMessage(event.nativeEvent.data)
+ if (!message) return
+ if (message.type === 'token') {
+ onTokenChange(message.token)
+ return
+ }
+ onTokenChange(null)
+ onError?.(CAPTCHA_WIDGET_ERROR_MESSAGE)
+ }
+
+ return (
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ width: '100%',
+ height: 140,
+ marginVertical: 8,
+ overflow: 'hidden',
+ },
+ webview: {
+ backgroundColor: 'transparent',
+ height: 140,
+ width: '100%',
+ },
+})
diff --git a/src/components/auth/TurnstileWidget.tsx b/src/components/auth/TurnstileWidget.tsx
new file mode 100644
index 0000000..fa7b23d
--- /dev/null
+++ b/src/components/auth/TurnstileWidget.tsx
@@ -0,0 +1,6 @@
+/**
+ * Metro resolves `TurnstileWidget.native.tsx` / `TurnstileWidget.web.tsx` before this file.
+ * This shim exists so TypeScript can resolve `@/components/auth/TurnstileWidget`.
+ */
+export type { TurnstileWidgetProps } from './TurnstileWidget.types'
+export { TurnstileWidget } from './TurnstileWidget.native'
diff --git a/src/components/auth/TurnstileWidget.types.ts b/src/components/auth/TurnstileWidget.types.ts
new file mode 100644
index 0000000..25a6165
--- /dev/null
+++ b/src/components/auth/TurnstileWidget.types.ts
@@ -0,0 +1,6 @@
+export interface TurnstileWidgetProps {
+ onTokenChange: (token: string | null) => void
+ onError?: (message: string) => void
+ /** Increment to remount and mint a fresh challenge after each auth attempt. */
+ resetNonce?: number
+}
diff --git a/src/components/auth/TurnstileWidget.web.tsx b/src/components/auth/TurnstileWidget.web.tsx
new file mode 100644
index 0000000..c1df714
--- /dev/null
+++ b/src/components/auth/TurnstileWidget.web.tsx
@@ -0,0 +1,41 @@
+import { StyleSheet, View } from 'react-native'
+import { Turnstile } from '@marsidev/react-turnstile'
+
+import type { TurnstileWidgetProps } from '@/components/auth/TurnstileWidget.types'
+import { CAPTCHA_WIDGET_ERROR_MESSAGE, getTurnstileSiteKey } from '@/lib/turnstile'
+
+export type { TurnstileWidgetProps }
+
+export function TurnstileWidget({ onTokenChange, onError, resetNonce = 0 }: TurnstileWidgetProps) {
+ const siteKey = getTurnstileSiteKey()
+ if (!siteKey) return null
+
+ return (
+
+ onTokenChange(token)}
+ onExpire={() => onTokenChange(null)}
+ onTimeout={() => {
+ onTokenChange(null)
+ onError?.(CAPTCHA_WIDGET_ERROR_MESSAGE)
+ }}
+ onError={() => {
+ onTokenChange(null)
+ onError?.(CAPTCHA_WIDGET_ERROR_MESSAGE)
+ }}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ width: '100%',
+ alignItems: 'center',
+ marginVertical: 8,
+ minHeight: 70,
+ },
+})
diff --git a/src/components/matches/EditMatchTeamModal.tsx b/src/components/matches/EditMatchTeamModal.tsx
index 26330b0..e4e6b87 100644
--- a/src/components/matches/EditMatchTeamModal.tsx
+++ b/src/components/matches/EditMatchTeamModal.tsx
@@ -37,6 +37,8 @@ type EditMatchTeamModalProps = {
* "Añadir amigo" tab is shown so the creator can invite friends. */
matchId?: string
team?: string
+ /** User ids already occupying a slot (participants + pending invites). */
+ occupiedUserIds?: string[]
/** Free slots on this team (registered + text + pending < 2). */
freeSlots?: number
/** Used in the WhatsApp invite message when inviting from this modal. */
@@ -64,6 +66,7 @@ type EditMatchTeamFormProps = {
slots: MatchTeamEditSlot[]
matchId?: string
team?: string
+ occupiedUserIds?: string[]
freeSlots?: number
matchTitle?: string
onClose: () => void
@@ -77,6 +80,7 @@ function EditMatchTeamForm({
slots,
matchId,
team,
+ occupiedUserIds,
freeSlots,
matchTitle,
onClose,
@@ -154,6 +158,7 @@ function EditMatchTeamForm({
teamLabel={teamLabel}
matchTitle={matchTitle}
freeSlots={freeSlots ?? 0}
+ occupiedUserIds={occupiedUserIds ?? []}
/>
) : (
<>
@@ -208,12 +213,14 @@ function InviteFriendsTab({
teamLabel,
matchTitle,
freeSlots,
+ occupiedUserIds,
}: {
matchId: string
team: string
teamLabel: string
matchTitle?: string
freeSlots: number
+ occupiedUserIds: string[]
}) {
const { data: friends, isLoading } = useMyFriends()
const invite = useInviteFriendToMatch()
@@ -221,9 +228,11 @@ function InviteFriendsTab({
const [invitingId, setInvitingId] = useState(null)
const remainingSlots = Math.max(0, freeSlots)
+ const occupied = new Set([...occupiedUserIds, ...invitedIds])
+ const eligibleFriends = (friends ?? []).filter((f) => !occupied.has(f.user_id))
const handleInvite = async (friendId: string) => {
- if (remainingSlots <= 0 || invitedIds.includes(friendId)) return
+ if (remainingSlots <= 0 || occupied.has(friendId)) return
setInvitingId(friendId)
try {
await invite.mutateAsync({ matchId, inviteeId: friendId, team })
@@ -264,11 +273,18 @@ function InviteFriendsTab({
)
}
+ if (eligibleFriends.length === 0) {
+ return (
+
+ Todos tus amigos ya están en esta partida o tienen una invitación pendiente.
+
+ )
+ }
+
return (
Invita a un amigo a unirse a {teamLabel}
- {friends.map((f) => {
- const alreadyInvited = invitedIds.includes(f.user_id)
+ {eligibleFriends.map((f) => {
return (
@@ -283,10 +299,10 @@ function InviteFriendsTab({
) : null}
void handleInvite(f.user_id)}
loading={invitingId === f.user_id}
- disabled={alreadyInvited || remainingSlots <= 0 || invitingId !== null}
+ disabled={remainingSlots <= 0 || invitingId !== null}
style={styles.inviteBtn}
textStyle={styles.inviteBtnText}
/>
@@ -304,6 +320,7 @@ export function EditMatchTeamModal({
slots,
matchId,
team,
+ occupiedUserIds,
freeSlots,
matchTitle,
onClose,
@@ -324,6 +341,7 @@ export function EditMatchTeamModal({
slots={slots}
matchId={matchId}
team={team}
+ occupiedUserIds={occupiedUserIds}
freeSlots={freeSlots}
matchTitle={matchTitle}
onClose={onClose}
diff --git a/src/components/profile/FriendsSection.tsx b/src/components/profile/FriendsSection.tsx
index 7728bf6..2d42958 100644
--- a/src/components/profile/FriendsSection.tsx
+++ b/src/components/profile/FriendsSection.tsx
@@ -1,9 +1,8 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import {
ActivityIndicator,
Alert,
Animated,
- Dimensions,
Modal,
Platform,
Pressable,
@@ -11,6 +10,7 @@ import {
StyleSheet,
Text,
TextInput,
+ useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
@@ -39,7 +39,6 @@ import { Fonts } from '@/theme/typography'
const FAB_SIZE = 56
const FAB_GAP_ABOVE_TAB_BAR = 6
-const PANEL_WIDTH = Math.min(360, Math.round(Dimensions.get('window').width * 0.86))
const SEARCH_DEBOUNCE_MS = 300
type FriendsSectionProps = {
@@ -53,11 +52,14 @@ export function FriendsSection({ bottom, right = 20 }: FriendsSectionProps) {
const router = useRouter()
const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
+ const { width: windowWidth } = useWindowDimensions()
+ const panelWidth = Math.min(360, Math.round(windowWidth * 0.86))
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [inviteTarget, setInviteTarget] = useState(null)
- const [slide] = useState(() => new Animated.Value(PANEL_WIDTH))
+ const [slide] = useState(() => new Animated.Value(panelWidth))
+ const inviteTimerRef = useRef | null>(null)
const bottomOffset = bottom ?? tabBarHeight + FAB_GAP_ABOVE_TAB_BAR
const { data: friends, isPending: friendsPending } = useMyFriends()
@@ -81,14 +83,23 @@ export function FriendsSection({ bottom, right = 20 }: FriendsSectionProps) {
}, [searchText])
useEffect(() => {
- if (!open) return
- slide.setValue(PANEL_WIDTH)
+ return () => {
+ if (inviteTimerRef.current) clearTimeout(inviteTimerRef.current)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!open) {
+ slide.setValue(panelWidth)
+ return
+ }
+ slide.setValue(panelWidth)
Animated.timing(slide, {
toValue: 0,
duration: 240,
useNativeDriver: true,
}).start()
- }, [open, slide])
+ }, [open, slide, panelWidth])
const clearDrawerState = () => {
setSearchText('')
@@ -97,7 +108,7 @@ export function FriendsSection({ bottom, right = 20 }: FriendsSectionProps) {
const animateDrawerClosed = (onClosed?: () => void) => {
Animated.timing(slide, {
- toValue: PANEL_WIDTH,
+ toValue: panelWidth,
duration: 200,
useNativeDriver: true,
}).start(() => {
@@ -118,7 +129,8 @@ export function FriendsSection({ bottom, right = 20 }: FriendsSectionProps) {
const showInvite = () => setInviteTarget(target)
// Give iOS time to dismiss the drawer Modal before presenting pageSheet.
if (Platform.OS === 'ios') {
- setTimeout(showInvite, 120)
+ if (inviteTimerRef.current) clearTimeout(inviteTimerRef.current)
+ inviteTimerRef.current = setTimeout(showInvite, 120)
} else {
showInvite()
}
@@ -134,7 +146,12 @@ export function FriendsSection({ bottom, right = 20 }: FriendsSectionProps) {
return (
<>
-
+ setMessage('')}
onRequestClose={() => {
if (!send.isPending) close()
}}>
diff --git a/src/hooks/useAuth.test.ts b/src/hooks/useAuth.test.ts
index e40e5b2..a8b42e6 100644
--- a/src/hooks/useAuth.test.ts
+++ b/src/hooks/useAuth.test.ts
@@ -42,6 +42,7 @@ jest.mock('expo-apple-authentication', () => ({
import { identifyUser } from '@/lib/analytics'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/hooks/useAuth'
+import { AUTH_PASSWORD_HINT } from '@/utils/authSchemas'
const mockSupabase = supabase as unknown as ReturnType
@@ -115,6 +116,32 @@ describe('useAuthStore', () => {
expect(error).toBeNull()
expect(identifyUser).toHaveBeenCalledWith('user-1')
})
+
+ it('forwards captchaToken to Auth', async () => {
+ mockSupabase.auth.signInWithPassword.mockResolvedValue({
+ data: { user: { id: 'user-1' }, session: null },
+ error: null,
+ })
+
+ await useAuthStore.getState().signInWithPassword('a@b.com', 'pass', 'cf-token')
+
+ expect(mockSupabase.auth.signInWithPassword).toHaveBeenCalledWith({
+ email: 'a@b.com',
+ password: 'pass',
+ options: { captchaToken: 'cf-token' },
+ })
+ })
+
+ it('maps captcha failures to a Spanish message', async () => {
+ mockSupabase.auth.signInWithPassword.mockResolvedValue({
+ data: { user: null, session: null },
+ error: { message: 'captcha verification process failed', code: 'captcha_failed' },
+ })
+
+ const { error } = await useAuthStore.getState().signInWithPassword('a@b.com', 'pass', 'bad')
+
+ expect(error?.message).toBe('Completa la verificación de seguridad e inténtalo de nuevo.')
+ })
})
describe('signUp', () => {
@@ -130,7 +157,41 @@ describe('useAuthStore', () => {
displayName: 'Test User',
})
- expect(error?.message).toBe('La contraseña no cumple los requisitos de seguridad')
+ expect(error?.message).toBe(AUTH_PASSWORD_HINT)
+ })
+
+ it('forwards captchaToken on signUp', async () => {
+ mockSupabase.auth.signUp.mockResolvedValue({
+ data: { user: { id: 'user-1' }, session: null },
+ error: null,
+ })
+
+ await useAuthStore.getState().signUp({
+ email: 'a@b.com',
+ password: 'ValidPass1',
+ displayName: 'Test User',
+ captchaToken: 'cf-token',
+ })
+
+ expect(mockSupabase.auth.signUp).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({ captchaToken: 'cf-token' }),
+ })
+ )
+ })
+ })
+
+ describe('resetPassword', () => {
+ it('forwards captchaToken to recover', async () => {
+ mockSupabase.auth.resetPasswordForEmail.mockResolvedValue({ data: {}, error: null })
+
+ const { error } = await useAuthStore.getState().resetPassword('a@b.com', 'cf-token')
+
+ expect(error).toBeNull()
+ expect(mockSupabase.auth.resetPasswordForEmail).toHaveBeenCalledWith('a@b.com', {
+ redirectTo: 'jugamus://auth/update-password',
+ captchaToken: 'cf-token',
+ })
})
})
@@ -145,5 +206,35 @@ describe('useAuthStore', () => {
expect(error?.message).toBe('La nueva contraseña debe ser distinta de la actual')
})
+
+ it('sends current_password and keeps the session when provided', async () => {
+ mockSupabase.auth.updateUser.mockResolvedValue({
+ data: { user: { id: 'user-1' } },
+ error: null,
+ })
+
+ const { error } = await useAuthStore.getState().updatePassword('NewPass1', 'OldPass1')
+
+ expect(error).toBeNull()
+ expect(mockSupabase.auth.updateUser).toHaveBeenCalledWith({
+ password: 'NewPass1',
+ current_password: 'OldPass1',
+ })
+ expect(mockSupabase.auth.signOut).not.toHaveBeenCalled()
+ })
+
+ it('maps current_password_mismatch', async () => {
+ mockSupabase.auth.updateUser.mockResolvedValue({
+ data: { user: null },
+ error: {
+ message: 'Current password required when setting new password.',
+ code: 'current_password_mismatch',
+ },
+ })
+
+ const { error } = await useAuthStore.getState().updatePassword('NewPass1', 'wrong')
+
+ expect(error?.message).toBe('La contraseña actual no es correcta')
+ })
})
})
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
index 1a8a7c8..dc978fb 100644
--- a/src/hooks/useAuth.ts
+++ b/src/hooks/useAuth.ts
@@ -16,6 +16,8 @@ import { signInWithOAuthProvider } from '@/lib/oauth'
import { clearSessionBackgroundMarker } from '@/lib/sessionBackground'
import { SESSION_EXPIRED_MESSAGE, validateAuthSession } from '@/lib/validateAuthSession'
import { supabase } from '@/lib/supabase'
+import { CAPTCHA_FAILED_MESSAGE } from '@/lib/turnstile'
+import { AUTH_PASSWORD_HINT } from '@/utils/authSchemas'
let authSubscription: { unsubscribe: () => void } | null = null
@@ -31,11 +33,29 @@ function userFacingAuthError(error: { message: string; status?: number; code?: s
return new Error('La nueva contraseña debe ser distinta de la actual')
}
if (code === 'weak_password' || /weak_password|password.*strength|at least/i.test(msg)) {
- return new Error('La contraseña no cumple los requisitos de seguridad')
+ return new Error(AUTH_PASSWORD_HINT)
+ }
+ if (
+ code === 'current_password_mismatch' ||
+ /incorrect current password|current_password_mismatch/i.test(msg)
+ ) {
+ return new Error('La contraseña actual no es correcta')
+ }
+ if (
+ code === 'current_password_required' ||
+ /current password required|current_password_required/i.test(msg)
+ ) {
+ return new Error('Introduce tu contraseña actual para cambiarla')
}
if (code === 'reauthentication_needed' || /reauthentication_needed|reauthenticate/i.test(msg)) {
return new Error('Debes volver a verificar tu identidad para cambiar la contraseña')
}
+ if (
+ code === 'captcha_failed' ||
+ /captcha verification|failed captcha|invalid captcha|captcha_failed/i.test(msg)
+ ) {
+ return new Error(CAPTCHA_FAILED_MESSAGE)
+ }
if (st === 429 || /429|rate limit|too many requests|too_many|over_email_send/i.test(msg)) {
return new Error(
'Límite temporal alcanzado (demasiadas peticiones). Espera 1–2 minutos, no pulses repetir varias veces, o prueba otra red. En cuentas de prueba, desactivar la confirmación por email en Supabase reduce estos límites.'
@@ -65,6 +85,7 @@ export interface SignUpParams {
email: string
password: string
displayName: string
+ captchaToken?: string
}
export interface AuthState {
@@ -81,12 +102,16 @@ export interface AuthState {
setPendingInviteHref: (href: string | null) => void
clearLastAuthMessage: () => void
initializeAuth: () => void
- signInWithPassword: (email: string, password: string) => Promise<{ error: Error | null }>
+ signInWithPassword: (
+ email: string,
+ password: string,
+ captchaToken?: string
+ ) => Promise<{ error: Error | null }>
signUp: (params: SignUpParams) => Promise<{ error: Error | null }>
signOut: () => Promise
deleteAccount: () => Promise<{ error: Error | null }>
- resetPassword: (email: string) => Promise<{ error: Error | null }>
- updatePassword: (password: string) => Promise<{ error: Error | null }>
+ resetPassword: (email: string, captchaToken?: string) => Promise<{ error: Error | null }>
+ updatePassword: (password: string, currentPassword?: string) => Promise<{ error: Error | null }>
signInWithGoogle: () => Promise<{ error: Error | null }>
signInWithApple: () => Promise<{ error: Error | null }>
/** Revalidates the persisted session with Auth; signs out locally if it is stale. */
@@ -202,8 +227,12 @@ export const useAuthStore = create((set, get) => ({
authSubscription = data.subscription
},
- signInWithPassword: async (email, password) => {
- const { data, error } = await supabase.auth.signInWithPassword({ email, password })
+ signInWithPassword: async (email, password, captchaToken) => {
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ ...(captchaToken ? { options: { captchaToken } } : {}),
+ })
if (error) {
return { error: userFacingAuthError(error) }
}
@@ -220,7 +249,7 @@ export const useAuthStore = create((set, get) => ({
return { error: null }
},
- signUp: async ({ email, password, displayName }) => {
+ signUp: async ({ email, password, displayName, captchaToken }) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
@@ -230,6 +259,7 @@ export const useAuthStore = create((set, get) => ({
display_name: displayName,
phone_e164: '+34000000000',
},
+ ...(captchaToken ? { captchaToken } : {}),
},
})
if (error) {
@@ -268,17 +298,23 @@ export const useAuthStore = create((set, get) => ({
return { error: null }
},
- resetPassword: async (email) => {
+ resetPassword: async (email, captchaToken) => {
const redirectTo = getPasswordResetRedirectUrl()
- const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo })
+ const { error } = await supabase.auth.resetPasswordForEmail(email, {
+ redirectTo,
+ ...(captchaToken ? { captchaToken } : {}),
+ })
if (error) {
return { error: userFacingAuthError(error) }
}
return { error: null }
},
- updatePassword: async (password) => {
- const { error } = await supabase.auth.updateUser({ password })
+ updatePassword: async (password, currentPassword) => {
+ const { error } = await supabase.auth.updateUser({
+ password,
+ ...(currentPassword ? { current_password: currentPassword } : {}),
+ })
if (error) {
return {
error: userFacingAuthError({
@@ -288,6 +324,9 @@ export const useAuthStore = create((set, get) => ({
}),
}
}
+ if (currentPassword) {
+ return { error: null }
+ }
await clearSessionBackgroundMarker()
await supabase.auth.signOut()
set({ session: null, passwordRecoveryPending: false })
diff --git a/src/hooks/useFriends.test.ts b/src/hooks/useFriends.test.ts
index edf9317..c1e60b8 100644
--- a/src/hooks/useFriends.test.ts
+++ b/src/hooks/useFriends.test.ts
@@ -12,6 +12,7 @@ import {
useMyFriendRequests,
useMyFriends,
useRespondFriendRequest,
+ useSearchUsersByDisplayName,
useSendFriendRequest,
userSearchQueryKey,
} from '@/hooks/useFriends'
@@ -31,6 +32,7 @@ import {
listMyFriendRequests,
listMyFriends,
respondFriendRequest,
+ searchUsersByDisplayName,
sendFriendRequest,
} from '@/services/friends.service'
@@ -38,6 +40,7 @@ const mockListMyFriends = listMyFriends as jest.Mock
const mockListMyFriendRequests = listMyFriendRequests as jest.Mock
const mockSendFriendRequest = sendFriendRequest as jest.Mock
const mockRespondFriendRequest = respondFriendRequest as jest.Mock
+const mockSearchUsers = searchUsersByDisplayName as jest.Mock
describe('friends query keys', () => {
it('builds stable keys', () => {
@@ -158,3 +161,32 @@ describe('useRespondFriendRequest', () => {
expect(mockRespondFriendRequest).toHaveBeenCalledWith('fr1', true)
})
})
+
+describe('useSearchUsersByDisplayName', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ useAuthStore.setState({ session: { user: { id: 'user-1' } } as never })
+ })
+
+ afterEach(() => {
+ useAuthStore.setState({ session: null })
+ })
+
+ it('does not search for a one-character query', async () => {
+ const { result } = renderHookWithClient(() => useSearchUsersByDisplayName('a'))
+
+ expect(result.current.fetchStatus).toBe('idle')
+ expect(mockSearchUsers).not.toHaveBeenCalled()
+ })
+
+ it('searches when the trimmed query has at least two characters', async () => {
+ const hits = [{ user_id: 'u2', display_name: 'Ana' }]
+ mockSearchUsers.mockResolvedValue(hits)
+
+ const { result } = renderHookWithClient(() => useSearchUsersByDisplayName('ana'))
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+ expect(mockSearchUsers).toHaveBeenCalledWith('ana')
+ expect(result.current.data).toEqual(hits)
+ })
+})
diff --git a/src/hooks/useMatchInvitations.test.ts b/src/hooks/useMatchInvitations.test.ts
index aee749c..307a896 100644
--- a/src/hooks/useMatchInvitations.test.ts
+++ b/src/hooks/useMatchInvitations.test.ts
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/hooks/useAuth'
import {
matchInvitationsQueryKey,
myMatchInvitationsQueryKey,
+ useCancelMatchInvitation,
useInviteFriendToMatch,
useMatchInvitations,
useMyMatchInvitations,
@@ -27,6 +28,7 @@ jest.mock('@/services/matchInvitations.service', () => ({
import { invalidateMyMatchesDashboard } from '@/hooks/useMatches'
import {
+ cancelMatchInvitation,
inviteFriendToMatch,
listMatchInvitations,
listMyMatchInvitations,
@@ -37,6 +39,7 @@ const mockListMy = listMyMatchInvitations as jest.Mock
const mockListMatch = listMatchInvitations as jest.Mock
const mockInvite = inviteFriendToMatch as jest.Mock
const mockRespond = respondMatchInvitation as jest.Mock
+const mockCancel = cancelMatchInvitation as jest.Mock
const mockInvalidateDashboard = invalidateMyMatchesDashboard as jest.Mock
describe('useMyMatchInvitations', () => {
@@ -131,3 +134,28 @@ describe('useRespondMatchInvitation', () => {
expect(mockInvalidateDashboard).toHaveBeenCalled()
})
})
+
+describe('useCancelMatchInvitation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ useAuthStore.setState({ session: { user: { id: 'user-1' } } as never })
+ })
+
+ afterEach(() => {
+ useAuthStore.setState({ session: null })
+ })
+
+ it('cancels and invalidates the match invitation query', async () => {
+ mockCancel.mockResolvedValue(undefined)
+ const { result, queryClient } = renderHookWithClient(() => useCancelMatchInvitation())
+ const spy = jest.spyOn(queryClient, 'invalidateQueries')
+
+ await act(async () => {
+ await result.current.mutateAsync({ invitationId: 'i1', matchId: 'm1' })
+ })
+
+ expect(mockCancel).toHaveBeenCalledWith('i1')
+ expect(spy).toHaveBeenCalledWith({ queryKey: matchInvitationsQueryKey('m1') })
+ expect(mockInvalidateDashboard).toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/useMatchInvitations.ts b/src/hooks/useMatchInvitations.ts
index 4d4dbed..40899ee 100644
--- a/src/hooks/useMatchInvitations.ts
+++ b/src/hooks/useMatchInvitations.ts
@@ -102,9 +102,13 @@ export function useCancelMatchInvitation() {
const queryClient = useQueryClient()
const userId = useAuthStore((s) => s.session?.user.id)
return useMutation({
- mutationFn: (invitationId: string) => cancelMatchInvitation(invitationId),
- onSuccess: () => {
- invalidateMatchInvitationQueries(queryClient, { userId })
+ mutationFn: ({ invitationId }: { invitationId: string; matchId?: string }) =>
+ cancelMatchInvitation(invitationId),
+ onSuccess: (_data, variables) => {
+ invalidateMatchInvitationQueries(queryClient, {
+ userId,
+ matchId: variables.matchId,
+ })
if (userId) {
invalidateMyMatchesDashboard(queryClient, userId)
}
diff --git a/src/hooks/useProfile.test.ts b/src/hooks/useProfile.test.ts
index 502151f..502b30d 100644
--- a/src/hooks/useProfile.test.ts
+++ b/src/hooks/useProfile.test.ts
@@ -9,6 +9,7 @@ import { profileQueryKey, useProfile, useUpdateProfile } from '@/hooks/useProfil
jest.mock('@/services/profiles.service', () => ({
getProfile: jest.fn(),
updateProfile: jest.fn(),
+ isOwnProfile: (row: object) => 'phone_e164' in row && 'notify_push' in row,
}))
import { getProfile, updateProfile } from '@/services/profiles.service'
@@ -33,7 +34,12 @@ describe('useProfile', () => {
})
it('loads profile for session user', async () => {
- const profile = { id: 'user-1', display_name: 'Ana' }
+ const profile = {
+ id: 'user-1',
+ display_name: 'Ana',
+ phone_e164: '+34600000000',
+ notify_push: true,
+ }
mockGetProfile.mockResolvedValue(profile)
const { result } = renderHookWithClient(() => useProfile())
diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts
index be4cec2..250bfac 100644
--- a/src/hooks/useProfile.ts
+++ b/src/hooks/useProfile.ts
@@ -3,10 +3,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getProfile,
getViewableUserProfile,
+ isOwnProfile,
updateProfile,
uploadAvatar,
} from '@/services/profiles.service'
-import type { ProfileUpdate } from '@/services/profiles.service'
+import type { ProfileRow, ProfileUpdate } from '@/services/profiles.service'
import { useAuthStore } from '@/hooks/useAuth'
export function profileQueryKey(userId: string) {
@@ -24,7 +25,11 @@ export function useProfile(userId?: string) {
return useQuery({
queryKey: profileQueryKey(resolvedId ?? ''),
- queryFn: () => getProfile(resolvedId!),
+ queryFn: async (): Promise => {
+ const row = await getProfile(resolvedId!)
+ if (!isOwnProfile(row)) throw new Error('Perfil no encontrado')
+ return row
+ },
enabled: Boolean(resolvedId),
})
}
@@ -63,7 +68,9 @@ export function useUploadAvatar() {
mutationFn: async (input: { uri: string; mimeType?: string | null }) => {
if (!sessionUserId) throw new Error('No autenticado')
await uploadAvatar(sessionUserId, input.uri, input.mimeType)
- return getProfile(sessionUserId)
+ const row = await getProfile(sessionUserId)
+ if (!isOwnProfile(row)) throw new Error('Perfil no encontrado')
+ return row
},
onSuccess: (updated) => {
queryClient.setQueryData(profileQueryKey(updated.id), updated)
diff --git a/src/hooks/useTurnstileCaptcha.test.ts b/src/hooks/useTurnstileCaptcha.test.ts
new file mode 100644
index 0000000..b173445
--- /dev/null
+++ b/src/hooks/useTurnstileCaptcha.test.ts
@@ -0,0 +1,71 @@
+/** @jest-environment jsdom */
+
+import { act, renderHook } from '@testing-library/react'
+
+import { useTurnstileCaptcha } from '@/hooks/useTurnstileCaptcha'
+
+describe('useTurnstileCaptcha', () => {
+ const prevKey = process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY
+
+ afterEach(() => {
+ if (prevKey === undefined) delete process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY
+ else process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY = prevKey
+ })
+
+ it('resolves immediately when Turnstile is disabled', async () => {
+ delete process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY
+ const { result } = renderHook(() => useTurnstileCaptcha())
+ expect(result.current.enabled).toBe(false)
+ await expect(result.current.solve()).resolves.toEqual({ error: null })
+ expect(result.current.visible).toBe(false)
+ })
+
+ it('opens on solve and resolves with the token', async () => {
+ process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY = '0x4AAAA-test'
+ const { result } = renderHook(() => useTurnstileCaptcha())
+
+ let solved: ReturnType | undefined
+ act(() => {
+ solved = result.current.solve()
+ })
+ expect(result.current.visible).toBe(true)
+
+ act(() => {
+ result.current.complete('cf-token')
+ })
+ await expect(solved).resolves.toEqual({ token: 'cf-token', error: null })
+ expect(result.current.visible).toBe(false)
+ })
+
+ it('treats dismiss as cancelled without an error', async () => {
+ process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY = '0x4AAAA-test'
+ const { result } = renderHook(() => useTurnstileCaptcha())
+
+ let solved: ReturnType | undefined
+ act(() => {
+ solved = result.current.solve()
+ })
+ act(() => {
+ result.current.cancel()
+ })
+ await expect(solved).resolves.toEqual({ error: null, cancelled: true })
+ })
+
+ it('does not replace an in-flight solve resolver', async () => {
+ process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY = '0x4AAAA-test'
+ const { result } = renderHook(() => useTurnstileCaptcha())
+
+ let first: ReturnType | undefined
+ let second: ReturnType | undefined
+ act(() => {
+ first = result.current.solve()
+ second = result.current.solve()
+ })
+ await expect(second).resolves.toEqual({ error: null, cancelled: true })
+
+ act(() => {
+ result.current.complete('cf-token')
+ })
+ await expect(first).resolves.toEqual({ token: 'cf-token', error: null })
+ })
+})
diff --git a/src/hooks/useTurnstileCaptcha.ts b/src/hooks/useTurnstileCaptcha.ts
new file mode 100644
index 0000000..167fe1b
--- /dev/null
+++ b/src/hooks/useTurnstileCaptcha.ts
@@ -0,0 +1,55 @@
+import { useCallback, useRef, useState } from 'react'
+
+import { CAPTCHA_REQUIRED_MESSAGE, isTurnstileEnabled } from '@/lib/turnstile'
+
+export type CaptchaSolveResult = {
+ token?: string
+ error: string | null
+ cancelled?: boolean
+}
+
+export function useTurnstileCaptcha() {
+ const enabled = isTurnstileEnabled()
+ const [visible, setVisible] = useState(false)
+ const [resetNonce, setResetNonce] = useState(0)
+ const pendingRef = useRef<((result: CaptchaSolveResult) => void) | null>(null)
+
+ const finish = useCallback((result: CaptchaSolveResult) => {
+ const resolve = pendingRef.current
+ pendingRef.current = null
+ setVisible(false)
+ resolve?.(result)
+ }, [])
+
+ const solve = useCallback((): Promise => {
+ if (!enabled) return Promise.resolve({ error: null })
+ if (pendingRef.current) {
+ return Promise.resolve({ error: null, cancelled: true })
+ }
+ setResetNonce((n) => n + 1)
+ setVisible(true)
+ return new Promise((resolve) => {
+ pendingRef.current = resolve
+ })
+ }, [enabled])
+
+ const complete = useCallback(
+ (token: string) => {
+ finish({ token, error: null })
+ },
+ [finish]
+ )
+
+ const fail = useCallback(
+ (message: string = CAPTCHA_REQUIRED_MESSAGE) => {
+ finish({ error: message })
+ },
+ [finish]
+ )
+
+ const cancel = useCallback(() => {
+ finish({ error: null, cancelled: true })
+ }, [finish])
+
+ return { enabled, visible, resetNonce, solve, complete, fail, cancel }
+}
diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts
index 7bd1711..6fe1c71 100644
--- a/src/lib/analytics.test.ts
+++ b/src/lib/analytics.test.ts
@@ -149,6 +149,11 @@ describe('analytics helpers', () => {
match_id: 'm1',
team: 'B',
})
+
+ trackMatchInviteAccepted(undefined, 'A')
+ expect(posthog.capture).toHaveBeenCalledWith('match_invite_accepted', {
+ team: 'A',
+ })
})
describe('trackMatchCompletedIfFinished', () => {
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 88f2a5c..ac4b93b 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -83,8 +83,11 @@ export function trackMatchInviteSent(matchId: string, inviteeId: string, team: s
})
}
-export function trackMatchInviteAccepted(matchId: string, team: string): void {
- captureEvent(AnalyticsEvents.MATCH_INVITE_ACCEPTED, { match_id: matchId, team })
+export function trackMatchInviteAccepted(matchId: string | undefined, team: string): void {
+ captureEvent(AnalyticsEvents.MATCH_INVITE_ACCEPTED, {
+ ...(matchId ? { match_id: matchId } : {}),
+ team,
+ })
}
async function loadCompletedMatchIds(): Promise {
diff --git a/src/lib/turnstile.test.ts b/src/lib/turnstile.test.ts
new file mode 100644
index 0000000..d6c893a
--- /dev/null
+++ b/src/lib/turnstile.test.ts
@@ -0,0 +1,97 @@
+import {
+ buildTurnstileWidgetHtml,
+ CAPTCHA_WIDGET_ERROR_MESSAGE,
+ DEFAULT_TURNSTILE_HOSTNAME,
+ getTurnstileHostname,
+ getTurnstileHostedPageUrl,
+ getTurnstileOrigin,
+ getTurnstileSiteKey,
+ getTurnstileWebViewSource,
+ isTurnstileEnabled,
+ parseTurnstileWebViewMessage,
+ TURNSTILE_DEV_BASE_URL,
+} from './turnstile'
+
+describe('turnstile', () => {
+ const prevKey = process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY
+ const prevHost = process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME
+ const prevInvite = process.env.EXPO_PUBLIC_INVITE_HOST
+
+ afterEach(() => {
+ if (prevKey === undefined) delete process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY
+ else process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY = prevKey
+ if (prevHost === undefined) delete process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME
+ else process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME = prevHost
+ if (prevInvite === undefined) delete process.env.EXPO_PUBLIC_INVITE_HOST
+ else process.env.EXPO_PUBLIC_INVITE_HOST = prevInvite
+ })
+
+ it('reads the public site key from env', () => {
+ process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY = ' 0x4AAAA-test '
+ expect(getTurnstileSiteKey()).toBe('0x4AAAA-test')
+ expect(isTurnstileEnabled()).toBe(true)
+ })
+
+ it('is disabled when the site key is missing', () => {
+ delete process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY
+ expect(getTurnstileSiteKey()).toBe('')
+ expect(isTurnstileEnabled()).toBe(false)
+ })
+
+ it('defaults native hostname to the invite host', () => {
+ delete process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME
+ delete process.env.EXPO_PUBLIC_INVITE_HOST
+ expect(getTurnstileHostname()).toBe(DEFAULT_TURNSTILE_HOSTNAME)
+ expect(getTurnstileOrigin()).toBe('https://musapp-731e1.web.app')
+ })
+
+ it('strips protocol from a custom hostname', () => {
+ process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME = 'https://www.jugamus.app/'
+ expect(getTurnstileHostname()).toBe('www.jugamus.app')
+ expect(getTurnstileOrigin()).toBe('https://www.jugamus.app')
+ })
+
+ it('builds the hosted challenge URL with the site key', () => {
+ process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME = 'musapp-731e1.web.app'
+ expect(getTurnstileHostedPageUrl('0x4AAAA-test')).toBe(
+ 'https://musapp-731e1.web.app/turnstile.html?k=0x4AAAA-test'
+ )
+ })
+
+ it('uses localhost HTML in development and a hosted page in release', () => {
+ const htmlSource = getTurnstileWebViewSource('0x4AAAA-test', { dev: true })
+ expect(htmlSource).toEqual({
+ html: buildTurnstileWidgetHtml('0x4AAAA-test'),
+ baseUrl: TURNSTILE_DEV_BASE_URL,
+ })
+
+ process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME = 'musapp-731e1.web.app'
+ expect(getTurnstileWebViewSource('0x4AAAA-test', { dev: false })).toEqual({
+ uri: 'https://musapp-731e1.web.app/turnstile.html?k=0x4AAAA-test',
+ })
+ })
+
+ it('parses token, error and expired WebView messages', () => {
+ expect(parseTurnstileWebViewMessage('{"type":"token","token":"cf-ok"}')).toEqual({
+ type: 'token',
+ token: 'cf-ok',
+ })
+ expect(parseTurnstileWebViewMessage('{"type":"error","code":"110200"}')).toEqual({
+ type: 'error',
+ code: '110200',
+ })
+ expect(parseTurnstileWebViewMessage('{"type":"expired"}')).toEqual({ type: 'expired' })
+ expect(parseTurnstileWebViewMessage('not-json')).toBeNull()
+ expect(parseTurnstileWebViewMessage('{"type":"token","token":""}')).toBeNull()
+ })
+
+ it('embeds the site key in widget HTML', () => {
+ const html = buildTurnstileWidgetHtml('0x4AAAA-test')
+ expect(html).toContain("sitekey: '0x4AAAA-test'")
+ expect(html).toContain('challenges.cloudflare.com/turnstile')
+ })
+
+ it('exposes a user-facing widget error message', () => {
+ expect(CAPTCHA_WIDGET_ERROR_MESSAGE).toContain('localhost')
+ })
+})
diff --git a/src/lib/turnstile.ts b/src/lib/turnstile.ts
new file mode 100644
index 0000000..61e9ddd
--- /dev/null
+++ b/src/lib/turnstile.ts
@@ -0,0 +1,115 @@
+/** Public Cloudflare Turnstile site key. The secret key never ships in the app. */
+export function getTurnstileSiteKey(): string {
+ return process.env.EXPO_PUBLIC_TURNSTILE_SITE_KEY?.trim() ?? ''
+}
+
+/** Production native page host (Firebase invite hosting). No protocol. */
+export const DEFAULT_TURNSTILE_HOSTNAME = 'musapp-731e1.web.app'
+
+/** Expo Go / injected HTML origin. Add this hostname on the Turnstile widget. */
+export const TURNSTILE_DEV_BASE_URL = 'http://localhost'
+
+export function getTurnstileHostname(): string {
+ const raw =
+ process.env.EXPO_PUBLIC_TURNSTILE_HOSTNAME?.trim() ||
+ process.env.EXPO_PUBLIC_INVITE_HOST?.trim() ||
+ DEFAULT_TURNSTILE_HOSTNAME
+ return raw.replace(/^https?:\/\//i, '').replace(/\/+$/, '')
+}
+
+export function getTurnstileOrigin(): string {
+ return `https://${getTurnstileHostname()}`
+}
+
+export function getTurnstileHostedPageUrl(siteKey: string): string {
+ return `${getTurnstileOrigin()}/turnstile.html?k=${encodeURIComponent(siteKey)}`
+}
+
+export function getTurnstileWebViewSource(
+ siteKey: string,
+ options?: { dev?: boolean }
+): { uri: string } | { html: string; baseUrl: string } {
+ const isDev = options?.dev ?? (typeof __DEV__ !== 'undefined' && __DEV__)
+ if (isDev) {
+ return { html: buildTurnstileWidgetHtml(siteKey), baseUrl: TURNSTILE_DEV_BASE_URL }
+ }
+ return { uri: getTurnstileHostedPageUrl(siteKey) }
+}
+
+export function isTurnstileEnabled(): boolean {
+ return getTurnstileSiteKey().length > 0
+}
+
+export const CAPTCHA_REQUIRED_MESSAGE = 'Completa la verificación de seguridad'
+export const CAPTCHA_FAILED_MESSAGE = 'Completa la verificación de seguridad e inténtalo de nuevo.'
+export const CAPTCHA_WIDGET_ERROR_MESSAGE =
+ 'No se pudo completar la verificación. Añade localhost (Expo Go) y musapp-731e1.web.app en el widget de Turnstile.'
+
+export type TurnstileWebViewMessage =
+ { type: 'token'; token: string } | { type: 'error'; code?: string } | { type: 'expired' }
+
+export function parseTurnstileWebViewMessage(raw: string): TurnstileWebViewMessage | null {
+ try {
+ const parsed = JSON.parse(raw) as { type?: unknown; token?: unknown; code?: unknown }
+ if (parsed.type === 'token' && typeof parsed.token === 'string' && parsed.token.length > 0) {
+ return { type: 'token', token: parsed.token }
+ }
+ if (parsed.type === 'error') {
+ return {
+ type: 'error',
+ code: typeof parsed.code === 'string' && parsed.code.length > 0 ? parsed.code : undefined,
+ }
+ }
+ if (parsed.type === 'expired') {
+ return { type: 'expired' }
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+function escapeForScriptString(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/
+
+
+
+
+
+
+
+
+
+
+
+`
+}
diff --git a/src/services/matchInvitations.service.test.ts b/src/services/matchInvitations.service.test.ts
index 9f9e829..519376f 100644
--- a/src/services/matchInvitations.service.test.ts
+++ b/src/services/matchInvitations.service.test.ts
@@ -77,6 +77,14 @@ describe('matchInvitations.service', () => {
})
})
+ it('respondMatchInvitation omits match_id when metadata is missing', async () => {
+ mockRpc({ data: null, error: null })
+ await respondMatchInvitation('inv-1', true)
+ expect(posthog.capture).toHaveBeenCalledWith('match_invite_accepted', {
+ team: '',
+ })
+ })
+
it('respondMatchInvitation does not track on reject', async () => {
mockRpc({ data: null, error: null })
await respondMatchInvitation('inv-1', false)
diff --git a/src/services/matchInvitations.service.ts b/src/services/matchInvitations.service.ts
index 4266eb0..86edc4e 100644
--- a/src/services/matchInvitations.service.ts
+++ b/src/services/matchInvitations.service.ts
@@ -82,7 +82,7 @@ export async function respondMatchInvitation(
})
if (error) throw new Error(mapInviteRpcError(error.message))
if (accept) {
- trackMatchInviteAccepted(meta?.matchId ?? invitationId, meta?.team ?? '')
+ trackMatchInviteAccepted(meta?.matchId, meta?.team ?? '')
}
}
diff --git a/src/services/profiles.service.test.ts b/src/services/profiles.service.test.ts
index 82a8f6c..5baebac 100644
--- a/src/services/profiles.service.test.ts
+++ b/src/services/profiles.service.test.ts
@@ -78,7 +78,7 @@ describe('profiles.service', () => {
await expect(getProfile('u1')).rejects.toThrow('Perfil no encontrado')
})
- it('queries profiles table for other users with empty phone', async () => {
+ it('queries profiles table for other users without phone or prefs', async () => {
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'viewer' } },
error: null,
@@ -108,8 +108,19 @@ describe('profiles.service', () => {
const profile = await getProfile('u2')
expect(mockSupabase.from).toHaveBeenCalledWith('profiles')
- expect(profile.phone_e164).toBe('')
- expect(profile.display_name).toBe('Otro')
+ expect(profile).toEqual({
+ id: 'u2',
+ display_name: 'Otro',
+ city: 'Barcelona',
+ photo_url: null,
+ badge_showcase: [],
+ role: 'user',
+ status: 'active',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ })
+ expect(profile).not.toHaveProperty('phone_e164')
+ expect(profile).not.toHaveProperty('notify_push')
})
it('throws on profiles query error', async () => {
diff --git a/src/services/profiles.service.ts b/src/services/profiles.service.ts
index c2138f9..f2cb115 100644
--- a/src/services/profiles.service.ts
+++ b/src/services/profiles.service.ts
@@ -27,6 +27,18 @@ export type ProfileRow = {
updated_at: string
}
+export type OtherUserProfileRow = {
+ id: string
+ display_name: string
+ photo_url: string | null
+ city: string | null
+ badge_showcase: string[]
+ role: string
+ status: string
+ created_at: string
+ updated_at: string
+}
+
export type PublicProfileRow = {
id: string
display_name: string
@@ -64,7 +76,11 @@ export type ProfileUpdate = Pick<
const ALLOWED_AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
-export async function getProfile(userId: string): Promise {
+export function isOwnProfile(row: ProfileRow | OtherUserProfileRow): row is ProfileRow {
+ return 'phone_e164' in row && 'notify_push' in row
+}
+
+export async function getProfile(userId: string): Promise {
const {
data: { user },
} = await supabase.auth.getUser()
@@ -95,18 +111,6 @@ export async function getProfile(userId: string): Promise {
status: data.status,
created_at: data.created_at,
updated_at: data.updated_at,
- phone_e164: '',
- notify_push: true,
- notify_on_join: true,
- notify_on_match_start: true,
- notify_on_match_edit: true,
- notify_on_match_cancel: true,
- notify_on_result: true,
- notify_on_reminder_24h: true,
- notify_on_reminder_2h: true,
- notify_on_reminder_in_progress: true,
- notify_on_friend_request: true,
- notify_on_match_invitation: true,
}
}
@@ -137,7 +141,9 @@ export async function updateProfile(userId: string, updates: ProfileUpdate): Pro
const { error } = await supabase.from('profiles').update(updates).eq('id', userId)
if (error) throw new Error(error.message)
- return getProfile(userId)
+ const row = await getProfile(userId)
+ if (!isOwnProfile(row)) throw new Error('Perfil no encontrado')
+ return row
}
/**
diff --git a/src/utils/authSchemas.test.ts b/src/utils/authSchemas.test.ts
index 1e403d6..a7d8aff 100644
--- a/src/utils/authSchemas.test.ts
+++ b/src/utils/authSchemas.test.ts
@@ -1,4 +1,5 @@
import {
+ changePasswordSchema,
forgotPasswordSchema,
loginSchema,
registerSchema,
@@ -18,34 +19,83 @@ describe('authSchemas', () => {
})
describe('registerSchema', () => {
+ const base = {
+ displayName: 'Ana',
+ email: 'a@b.com',
+ acceptTerms: true,
+ }
+
it('requires matching passwords and terms acceptance', () => {
const ok = registerSchema.safeParse({
- displayName: 'Ana',
- email: 'a@b.com',
- password: 'password1',
- confirmPassword: 'password1',
- acceptTerms: true,
+ ...base,
+ password: 'Password1!',
+ confirmPassword: 'Password1!',
})
expect(ok.success).toBe(true)
const mismatch = registerSchema.safeParse({
- displayName: 'Ana',
- email: 'a@b.com',
- password: 'password1',
+ ...base,
+ password: 'Password1!',
confirmPassword: 'other',
- acceptTerms: true,
})
expect(mismatch.success).toBe(false)
const noTerms = registerSchema.safeParse({
- displayName: 'Ana',
- email: 'a@b.com',
- password: 'password1',
- confirmPassword: 'password1',
+ ...base,
+ password: 'Password1!',
+ confirmPassword: 'Password1!',
acceptTerms: false,
})
expect(noTerms.success).toBe(false)
})
+
+ it('rejects passwords missing uppercase, lowercase, a digit or a symbol', () => {
+ expect(
+ registerSchema.safeParse({
+ ...base,
+ password: 'password1!',
+ confirmPassword: 'password1!',
+ }).success
+ ).toBe(false)
+ expect(
+ registerSchema.safeParse({
+ ...base,
+ password: 'PASSWORD1!',
+ confirmPassword: 'PASSWORD1!',
+ }).success
+ ).toBe(false)
+ expect(
+ registerSchema.safeParse({
+ ...base,
+ password: 'Password!',
+ confirmPassword: 'Password!',
+ }).success
+ ).toBe(false)
+ expect(
+ registerSchema.safeParse({
+ ...base,
+ password: 'Password1',
+ confirmPassword: 'Password1',
+ }).success
+ ).toBe(false)
+ })
+
+ it('rejects trailing spaces and undocumented unicode symbols', () => {
+ expect(
+ registerSchema.safeParse({
+ ...base,
+ password: 'Password1 ',
+ confirmPassword: 'Password1 ',
+ }).success
+ ).toBe(false)
+ expect(
+ registerSchema.safeParse({
+ ...base,
+ password: 'Password1€',
+ confirmPassword: 'Password1€',
+ }).success
+ ).toBe(false)
+ })
})
describe('forgotPasswordSchema', () => {
@@ -55,11 +105,11 @@ describe('authSchemas', () => {
})
describe('updatePasswordSchema', () => {
- it('requires matching passwords with min length', () => {
+ it('requires matching passwords that meet Auth complexity', () => {
expect(
updatePasswordSchema.safeParse({
- password: '12345678',
- confirmPassword: '12345678',
+ password: 'Password1!',
+ confirmPassword: 'Password1!',
}).success
).toBe(true)
@@ -73,7 +123,40 @@ describe('authSchemas', () => {
expect(
updatePasswordSchema.safeParse({
password: '12345678',
- confirmPassword: '87654321',
+ confirmPassword: '12345678',
+ }).success
+ ).toBe(false)
+
+ expect(
+ updatePasswordSchema.safeParse({
+ password: 'Password1!',
+ confirmPassword: 'Password2!',
+ }).success
+ ).toBe(false)
+ })
+ })
+
+ describe('changePasswordSchema', () => {
+ const valid = {
+ currentPassword: 'OldPass1!',
+ password: 'NewPass1!',
+ confirmPassword: 'NewPass1!',
+ }
+
+ it('requires current password and a different matching new password', () => {
+ expect(changePasswordSchema.safeParse(valid).success).toBe(true)
+
+ expect(changePasswordSchema.safeParse({ ...valid, currentPassword: '' }).success).toBe(false)
+
+ expect(
+ changePasswordSchema.safeParse({ ...valid, confirmPassword: 'OtherPass1' }).success
+ ).toBe(false)
+
+ expect(
+ changePasswordSchema.safeParse({
+ currentPassword: 'SamePass1!',
+ password: 'SamePass1!',
+ confirmPassword: 'SamePass1!',
}).success
).toBe(false)
})
diff --git a/src/utils/authSchemas.ts b/src/utils/authSchemas.ts
index 111e540..1cb6e6b 100644
--- a/src/utils/authSchemas.ts
+++ b/src/utils/authSchemas.ts
@@ -1,5 +1,22 @@
import { z } from 'zod'
+/** Matches Supabase Auth: min 8 + lowercase + uppercase + digits + symbols. */
+export const AUTH_PASSWORD_HINT =
+ 'Mínimo 8 caracteres, con mayúscula, minúscula, un número y un símbolo.'
+
+/** Letters, digits, and the symbol set documented by Supabase Auth / GoTrue. */
+const AUTH_PASSWORD_ALLOWED = /^[A-Za-z0-9!@#$%^&*()_+\-=[\]{}|;:,.<>?]+$/
+const AUTH_PASSWORD_SYMBOL = /[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/
+
+const authPasswordSchema = z
+ .string()
+ .min(8, 'La contraseña debe tener al menos 8 caracteres')
+ .regex(/[a-z]/, AUTH_PASSWORD_HINT)
+ .regex(/[A-Z]/, AUTH_PASSWORD_HINT)
+ .regex(/[0-9]/, AUTH_PASSWORD_HINT)
+ .regex(AUTH_PASSWORD_SYMBOL, AUTH_PASSWORD_HINT)
+ .regex(AUTH_PASSWORD_ALLOWED, AUTH_PASSWORD_HINT)
+
export const loginSchema = z.object({
email: z.string().trim().email('Email no válido'),
password: z.string().min(1, 'Introduce la contraseña'),
@@ -11,7 +28,7 @@ export const registerSchema = z
.object({
displayName: z.string().trim().min(2, 'El nombre debe tener al menos 2 caracteres'),
email: z.string().trim().email('Email no válido'),
- password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
+ password: authPasswordSchema,
confirmPassword: z.string(),
acceptTerms: z.boolean().refine((v) => v === true, {
message: 'Debes aceptar los términos y la política de privacidad',
@@ -32,7 +49,7 @@ export type ForgotPasswordFormValues = z.infer
export const updatePasswordSchema = z
.object({
- password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
+ password: authPasswordSchema,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
@@ -41,3 +58,20 @@ export const updatePasswordSchema = z
})
export type UpdatePasswordFormValues = z.infer
+
+export const changePasswordSchema = z
+ .object({
+ currentPassword: z.string().min(1, 'Introduce tu contraseña actual'),
+ password: authPasswordSchema,
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: 'Las contraseñas no coinciden',
+ path: ['confirmPassword'],
+ })
+ .refine((data) => data.password !== data.currentPassword, {
+ message: 'La nueva contraseña debe ser distinta de la actual',
+ path: ['password'],
+ })
+
+export type ChangePasswordFormValues = z.infer
diff --git a/src/utils/leagueFixtures.test.ts b/src/utils/leagueFixtures.test.ts
index 95b0777..5fa70bd 100644
--- a/src/utils/leagueFixtures.test.ts
+++ b/src/utils/leagueFixtures.test.ts
@@ -53,6 +53,15 @@ describe('generateRoundRobinFixtures', () => {
const count = fixtures.filter((f) => f.pairAId === id || f.pairBId === id).length
expect(count).toBe(2)
}
+ const pairs = fixtures.map((f) => [f.pairAId, f.pairBId].sort().join('|'))
+ expect(new Set(pairs).size).toBe(pairs.length)
+ const expected = new Set()
+ for (let i = 0; i < ids.length; i++) {
+ for (let j = i + 1; j < ids.length; j++) {
+ expected.add([ids[i], ids[j]].sort().join('|'))
+ }
+ }
+ expect(new Set(pairs)).toEqual(expected)
})
it('returns empty for fewer than 2 pairs', () => {
diff --git a/src/utils/municipalities.test.ts b/src/utils/municipalities.test.ts
index c6a6a96..5be0cd9 100644
--- a/src/utils/municipalities.test.ts
+++ b/src/utils/municipalities.test.ts
@@ -13,6 +13,12 @@ describe('searchMunicipalities', () => {
expect(results.every((m) => typeof m.code === 'string' && typeof m.name === 'string')).toBe(
true
)
- expect(searchMunicipalities('MADRÍD', 3).length).toBeGreaterThan(0)
+ expect(results.some((m) => m.name.toLowerCase().includes('madrid'))).toBe(true)
+
+ const accented = searchMunicipalities('MADRÍD', 3)
+ expect(accented.length).toBeGreaterThan(0)
+ expect(
+ accented.some((m) => /madrid/i.test(m.name.normalize('NFD').replace(/[\u0300-\u036f]/g, '')))
+ ).toBe(true)
})
})
diff --git a/supabase/migrations/20260813100000_118_security_rls_and_rpc_lockdown.sql b/supabase/migrations/20260813100000_118_security_rls_and_rpc_lockdown.sql
new file mode 100644
index 0000000..7e79f55
--- /dev/null
+++ b/supabase/migrations/20260813100000_118_security_rls_and_rpc_lockdown.sql
@@ -0,0 +1,100 @@
+-- 118: Security follow-up — RLS gaps and over-exposed SECURITY DEFINER RPCs
+-- Findings from production audit (ago. 2026):
+-- 1. player_stats_recompute_queue: RLS off + full grants to anon/authenticated
+-- 2. match_invitations SELECT policy: unqualified `match_id` resolved to
+-- mp.match_id (always true) → any confirmed participant could read all invites
+-- 3. Cron/lifecycle RPCs still EXECUTE for anon (CREATE OR REPLACE re-grants PUBLIC)
+-- 4. player_stats SELECT USING (true) bypasses get_player_stats visibility gate
+-- 5. Internal `_` helpers still executable by clients
+
+-- ── 1. Lock down player_stats_recompute_queue ─────────────────────────────────
+ALTER TABLE public.player_stats_recompute_queue ENABLE ROW LEVEL SECURITY;
+-- No client policies: only service_role / SECURITY DEFINER internals may touch it.
+REVOKE ALL ON TABLE public.player_stats_recompute_queue FROM PUBLIC;
+REVOKE ALL ON TABLE public.player_stats_recompute_queue FROM anon;
+REVOKE ALL ON TABLE public.player_stats_recompute_queue FROM authenticated;
+
+-- ── 2. Fix match_invitations SELECT (qualify outer match_id) ──────────────────
+DROP POLICY IF EXISTS match_invitations_select_party ON public.match_invitations;
+
+CREATE POLICY match_invitations_select_party ON public.match_invitations
+ FOR SELECT TO authenticated
+ USING (
+ inviter_id = auth.uid()
+ OR invitee_id = auth.uid()
+ OR EXISTS (
+ SELECT 1 FROM public.matches m
+ WHERE m.id = match_invitations.match_id
+ AND m.creator_id = auth.uid()
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.match_participants mp
+ WHERE mp.match_id = match_invitations.match_id
+ AND mp.user_id = auth.uid()
+ AND mp.state = 'confirmed'
+ AND mp.left_at IS NULL
+ )
+ OR public.auth_is_admin()
+ );
+
+-- ── 3. Revoke cron / lifecycle / enqueue from client roles ────────────────────
+-- pg_cron runs as a privileged DB role and keeps EXECUTE regardless.
+REVOKE ALL ON FUNCTION public.process_match_state_transitions() FROM PUBLIC;
+REVOKE ALL ON FUNCTION public.process_match_state_transitions() FROM anon;
+REVOKE ALL ON FUNCTION public.process_match_state_transitions() FROM authenticated;
+
+REVOKE ALL ON FUNCTION public.process_tournament_lifecycle() FROM PUBLIC;
+REVOKE ALL ON FUNCTION public.process_tournament_lifecycle() FROM anon;
+REVOKE ALL ON FUNCTION public.process_tournament_lifecycle() FROM authenticated;
+
+REVOKE ALL ON FUNCTION public.enqueue_notification(UUID, TEXT, TEXT, TEXT, JSONB, TIMESTAMPTZ) FROM PUBLIC;
+REVOKE ALL ON FUNCTION public.enqueue_notification(UUID, TEXT, TEXT, TEXT, JSONB, TIMESTAMPTZ) FROM anon;
+REVOKE ALL ON FUNCTION public.enqueue_notification(UUID, TEXT, TEXT, TEXT, JSONB, TIMESTAMPTZ) FROM authenticated;
+
+-- Keep league lifecycle locked (idempotent with 106).
+REVOKE ALL ON FUNCTION public.process_league_lifecycle() FROM PUBLIC;
+REVOKE ALL ON FUNCTION public.process_league_lifecycle() FROM anon;
+REVOKE ALL ON FUNCTION public.process_league_lifecycle() FROM authenticated;
+
+-- ── 4. Gate player_stats table SELECT to profile visibility ───────────────────
+-- App reads stats via get_player_stats / get_leaderboard / get_player_ranking RPCs.
+DROP POLICY IF EXISTS player_stats_select_authenticated ON public.player_stats;
+
+CREATE POLICY player_stats_select_authenticated ON public.player_stats
+ FOR SELECT TO authenticated
+ USING (
+ public.profile_is_viewable_by_auth(user_id)
+ OR public.auth_is_admin()
+ );
+
+REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON TABLE public.player_stats FROM anon;
+REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON TABLE public.player_stats FROM authenticated;
+GRANT SELECT ON TABLE public.player_stats TO authenticated;
+
+-- ── 5. Revoke internal underscore helpers from clients ────────────────────────
+REVOKE ALL ON FUNCTION public._finished_league_ranks_for_user(UUID) FROM PUBLIC;
+REVOKE ALL ON FUNCTION public._finished_league_ranks_for_user(UUID) FROM anon;
+REVOKE ALL ON FUNCTION public._finished_league_ranks_for_user(UUID) FROM authenticated;
+
+REVOKE ALL ON FUNCTION public._player_broke_nine_win_streak(UUID) FROM PUBLIC;
+REVOKE ALL ON FUNCTION public._player_broke_nine_win_streak(UUID) FROM anon;
+REVOKE ALL ON FUNCTION public._player_broke_nine_win_streak(UUID) FROM authenticated;
+
+REVOKE ALL ON FUNCTION public._player_confirmed_match_rows(UUID) FROM PUBLIC;
+REVOKE ALL ON FUNCTION public._player_confirmed_match_rows(UUID) FROM anon;
+REVOKE ALL ON FUNCTION public._player_confirmed_match_rows(UUID) FROM authenticated;
+
+REVOKE ALL ON FUNCTION public._player_won_match(TEXT, INTEGER, INTEGER) FROM PUBLIC;
+REVOKE ALL ON FUNCTION public._player_won_match(TEXT, INTEGER, INTEGER) FROM anon;
+REVOKE ALL ON FUNCTION public._player_won_match(TEXT, INTEGER, INTEGER) FROM authenticated;
+
+-- ── 6. Defense-in-depth: sensitive tables — no broad client DML grants ────────
+REVOKE ALL ON TABLE public.notification_queue FROM PUBLIC;
+REVOKE ALL ON TABLE public.notification_queue FROM anon;
+REVOKE ALL ON TABLE public.notification_queue FROM authenticated;
+GRANT SELECT ON TABLE public.notification_queue TO authenticated;
+
+REVOKE ALL ON TABLE public.audit_logs FROM PUBLIC;
+REVOKE ALL ON TABLE public.audit_logs FROM anon;
+REVOKE ALL ON TABLE public.audit_logs FROM authenticated;
+GRANT SELECT ON TABLE public.audit_logs TO authenticated;
diff --git a/supabase/migrations/20260813110000_119_search_path_tournament_helpers.sql b/supabase/migrations/20260813110000_119_search_path_tournament_helpers.sql
new file mode 100644
index 0000000..49cdd2f
--- /dev/null
+++ b/supabase/migrations/20260813110000_119_search_path_tournament_helpers.sql
@@ -0,0 +1,19 @@
+-- 119: Pin search_path on remaining public helpers flagged by the security advisor
+-- (lint 0011 function_search_path_mutable).
+-- These SQL helpers already qualify objects as public.*; pinning search_path
+-- prevents a caller from injecting a malicious schema ahead of public.
+--
+-- HaveIBeenPwned leaked-password protection is Auth dashboard config (Pro+),
+-- not SQL. The jugaMUS org is on the Free plan, so it cannot be enabled here.
+
+ALTER FUNCTION public.tournament_pair_is_complete(uuid, text, uuid, text)
+ SET search_path = public;
+
+ALTER FUNCTION public.tournament_match_title(text, integer, boolean)
+ SET search_path = public;
+
+ALTER FUNCTION public.tournament_round_name(integer)
+ SET search_path = public;
+
+ALTER FUNCTION public.user_is_in_tournament_pair(uuid, uuid, uuid)
+ SET search_path = public;
diff --git a/supabase/migrations/20260813120000_120_pr_review_followups.sql b/supabase/migrations/20260813120000_120_pr_review_followups.sql
new file mode 100644
index 0000000..286b045
--- /dev/null
+++ b/supabase/migrations/20260813120000_120_pr_review_followups.sql
@@ -0,0 +1,350 @@
+-- 120: PR review follow-ups (applied 108–119 already live; do not edit those files).
+-- - Count pending invitations as SECURITY DEFINER (not filtered by caller RLS).
+-- - Search by lower(display_name) to match profiles_display_name_trgm_idx.
+-- - Recreate list_my_match_invitations with DROP + REVOKE/GRANT.
+-- - Idempotent realtime publication adds.
+-- - join_private_match respects pending-invite team capacity.
+-- - Friend-request cooldown only for the original requester.
+
+CREATE OR REPLACE FUNCTION public.match_pending_invitations_filled(p_match_id uuid)
+RETURNS integer
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+ SELECT COUNT(*)::integer
+ FROM public.match_invitations mi
+ WHERE mi.match_id = p_match_id
+ AND mi.status = 'pending';
+$$;
+
+REVOKE ALL ON FUNCTION public.match_pending_invitations_filled(UUID) FROM PUBLIC;
+REVOKE ALL ON FUNCTION public.match_pending_invitations_filled(UUID) FROM anon;
+REVOKE ALL ON FUNCTION public.match_pending_invitations_filled(UUID) FROM authenticated;
+
+CREATE OR REPLACE FUNCTION public.search_users_by_display_name(
+ p_query TEXT,
+ p_limit INT DEFAULT 20
+)
+RETURNS TABLE (
+ user_id UUID,
+ display_name TEXT,
+ city TEXT,
+ photo_url TEXT,
+ friendship_status TEXT,
+ friendship_direction TEXT
+)
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_self UUID := auth.uid();
+ v_q TEXT := NULLIF(BTRIM(COALESCE(p_query, '')), '');
+ v_pattern TEXT;
+ v_limit INT := LEAST(GREATEST(COALESCE(p_limit, 20), 1), 30);
+BEGIN
+ IF v_self IS NULL THEN
+ RAISE EXCEPTION 'not_authenticated';
+ END IF;
+
+ IF v_q IS NULL OR char_length(v_q) < 2 THEN
+ RETURN;
+ END IF;
+
+ v_pattern := '%' || replace(replace(replace(v_q, '\', '\\'), '%', '\%'), '_', '\_') || '%';
+
+ RETURN QUERY
+ SELECT
+ p.id AS user_id,
+ p.display_name,
+ p.city,
+ p.photo_url,
+ f.status AS friendship_status,
+ CASE
+ WHEN f.id IS NULL THEN NULL
+ WHEN f.requester_id = v_self THEN 'sent'
+ WHEN f.addressee_id = v_self THEN 'received'
+ ELSE NULL
+ END AS friendship_direction
+ FROM public.profiles p
+ LEFT JOIN public.friendships f
+ ON f.status IN ('pending', 'accepted')
+ AND LEAST(f.requester_id, f.addressee_id) = LEAST(v_self, p.id)
+ AND GREATEST(f.requester_id, f.addressee_id) = GREATEST(v_self, p.id)
+ WHERE p.status = 'active'
+ AND p.id <> v_self
+ AND lower(p.display_name) LIKE lower(v_pattern) ESCAPE '\'
+ ORDER BY
+ CASE WHEN lower(p.display_name) = lower(v_q) THEN 0 ELSE 1 END,
+ p.display_name
+ LIMIT v_limit;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.search_users_by_display_name(TEXT, INT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.search_users_by_display_name(TEXT, INT) TO authenticated;
+
+DROP FUNCTION IF EXISTS public.list_my_match_invitations();
+
+CREATE FUNCTION public.list_my_match_invitations()
+RETURNS TABLE (
+ invitation_id UUID,
+ match_id UUID,
+ title TEXT,
+ start_at TIMESTAMPTZ,
+ match_status TEXT,
+ inviter_id UUID,
+ inviter_name TEXT,
+ team TEXT,
+ created_at TIMESTAMPTZ
+)
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT
+ mi.id AS invitation_id,
+ mi.match_id,
+ m.title,
+ m.start_at,
+ m.status AS match_status,
+ mi.inviter_id,
+ p.display_name AS inviter_name,
+ mi.team,
+ mi.created_at
+ FROM public.match_invitations mi
+ JOIN public.matches m ON m.id = mi.match_id
+ JOIN public.profiles p ON p.id = mi.inviter_id
+ WHERE mi.invitee_id = auth.uid()
+ AND mi.status = 'pending'
+ AND m.status <> 'cancelled'
+ ORDER BY mi.created_at DESC;
+$$;
+
+REVOKE ALL ON FUNCTION public.list_my_match_invitations() FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.list_my_match_invitations() TO authenticated;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'supabase_realtime'
+ AND schemaname = 'public'
+ AND tablename = 'friendships'
+ ) THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.friendships;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_publication_tables
+ WHERE pubname = 'supabase_realtime'
+ AND schemaname = 'public'
+ AND tablename = 'match_invitations'
+ ) THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.match_invitations;
+ END IF;
+END $$;
+
+CREATE OR REPLACE FUNCTION public.join_private_match(
+ p_match_id UUID,
+ p_team TEXT,
+ p_password TEXT
+)
+RETURNS public.match_participants
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, extensions
+AS $$
+DECLARE
+ v_match public.matches%ROWTYPE;
+ v_existing public.match_participants%ROWTYPE;
+ v_row public.match_participants%ROWTYPE;
+BEGIN
+ IF auth.uid() IS NULL THEN
+ RAISE EXCEPTION 'not_authenticated';
+ END IF;
+
+ SELECT * INTO v_match FROM public.matches WHERE id = p_match_id FOR SHARE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'match_not_found';
+ END IF;
+
+ IF v_match.visibility <> 'private' THEN
+ RAISE EXCEPTION 'not_private_match';
+ END IF;
+
+ IF v_match.status NOT IN ('planned', 'in_progress') THEN
+ RAISE EXCEPTION 'match_not_joinable';
+ END IF;
+
+ IF v_match.tournament_id IS NOT NULL THEN
+ RAISE EXCEPTION 'tournament_match';
+ END IF;
+
+ IF v_match.league_id IS NOT NULL THEN
+ RAISE EXCEPTION 'league_match';
+ END IF;
+
+ IF v_match.password_hash IS NULL THEN
+ RAISE EXCEPTION 'match_no_password';
+ END IF;
+
+ IF crypt(p_password, v_match.password_hash) <> v_match.password_hash THEN
+ RAISE EXCEPTION 'wrong_password';
+ END IF;
+
+ SELECT * INTO v_existing
+ FROM public.match_participants
+ WHERE match_id = p_match_id AND user_id = auth.uid();
+
+ IF FOUND THEN
+ IF v_existing.left_at IS NULL AND v_existing.state = 'confirmed' THEN
+ RAISE EXCEPTION 'already_participant';
+ END IF;
+
+ IF NOT public.inviter_team_capacity_available(p_match_id, p_team) THEN
+ RAISE EXCEPTION 'team_capacity_exceeded';
+ END IF;
+
+ UPDATE public.match_participants
+ SET
+ team = p_team,
+ state = 'confirmed',
+ left_at = NULL,
+ joined_at = NOW()
+ WHERE id = v_existing.id
+ RETURNING * INTO v_row;
+ ELSE
+ IF NOT public.inviter_team_capacity_available(p_match_id, p_team) THEN
+ RAISE EXCEPTION 'team_capacity_exceeded';
+ END IF;
+
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, auth.uid(), p_team)
+ RETURNING * INTO v_row;
+ END IF;
+
+ RETURN v_row;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.join_private_match(UUID, TEXT, TEXT) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.join_private_match(UUID, TEXT, TEXT) TO authenticated;
+
+DROP FUNCTION IF EXISTS public.send_friend_request(UUID, TEXT);
+
+CREATE FUNCTION public.send_friend_request(
+ p_addressee_id UUID,
+ p_message TEXT DEFAULT NULL
+)
+RETURNS TABLE (friendship_id UUID, status TEXT)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_self UUID := auth.uid();
+ v_row public.friendships%ROWTYPE;
+ v_name TEXT;
+ v_msg TEXT := NULLIF(BTRIM(LEFT(COALESCE(p_message, ''), 200)), '');
+BEGIN
+ IF v_self IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+ IF p_addressee_id IS NULL THEN RAISE EXCEPTION 'addressee_required'; END IF;
+ IF p_addressee_id = v_self THEN RAISE EXCEPTION 'cannot_friend_self'; END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM public.profiles
+ WHERE id = p_addressee_id AND status = 'active'
+ ) THEN
+ RAISE EXCEPTION 'addressee_not_found';
+ END IF;
+
+ SELECT * INTO v_row
+ FROM public.friendships
+ WHERE LEAST(requester_id, addressee_id) = LEAST(v_self, p_addressee_id)
+ AND GREATEST(requester_id, addressee_id) = GREATEST(v_self, p_addressee_id)
+ FOR UPDATE;
+
+ IF FOUND THEN
+ IF v_row.status = 'accepted' THEN
+ RAISE EXCEPTION 'already_friends';
+ END IF;
+ IF v_row.status = 'pending' THEN
+ IF v_row.requester_id = p_addressee_id THEN
+ UPDATE public.friendships
+ SET status = 'accepted', responded_at = NOW()
+ WHERE id = v_row.id
+ RETURNING * INTO v_row;
+
+ SELECT display_name INTO v_name FROM public.profiles WHERE id = v_self;
+ PERFORM public.enqueue_notification(
+ p_user_id := p_addressee_id,
+ p_type := 'friend_request_accepted',
+ p_title := 'Solicitud aceptada',
+ p_body := COALESCE(v_name, 'Alguien') || ' ha aceptado tu solicitud de amistad',
+ p_payload_json := jsonb_build_object('friendship_id', v_row.id, 'user_id', v_self)
+ );
+ friendship_id := v_row.id;
+ status := v_row.status;
+ RETURN NEXT;
+ RETURN;
+ END IF;
+ RAISE EXCEPTION 'request_already_pending';
+ END IF;
+
+ IF v_row.status = 'rejected'
+ AND v_row.responded_at IS NOT NULL
+ AND v_row.responded_at > NOW() - INTERVAL '7 days'
+ AND v_row.requester_id = v_self
+ THEN
+ RAISE EXCEPTION 'request_recently_rejected';
+ END IF;
+
+ UPDATE public.friendships
+ SET requester_id = v_self,
+ addressee_id = p_addressee_id,
+ message = v_msg,
+ status = 'pending',
+ created_at = NOW(),
+ responded_at = NULL
+ WHERE id = v_row.id
+ RETURNING * INTO v_row;
+ ELSE
+ BEGIN
+ INSERT INTO public.friendships (requester_id, addressee_id, message)
+ VALUES (v_self, p_addressee_id, v_msg)
+ RETURNING * INTO v_row;
+ EXCEPTION
+ WHEN unique_violation THEN
+ SELECT * INTO v_row
+ FROM public.friendships
+ WHERE LEAST(requester_id, addressee_id) = LEAST(v_self, p_addressee_id)
+ AND GREATEST(requester_id, addressee_id) = GREATEST(v_self, p_addressee_id);
+ IF v_row.status = 'accepted' THEN
+ RAISE EXCEPTION 'already_friends';
+ END IF;
+ RAISE EXCEPTION 'request_already_pending';
+ END;
+ END IF;
+
+ SELECT display_name INTO v_name FROM public.profiles WHERE id = v_self;
+
+ PERFORM public.enqueue_notification(
+ p_user_id := p_addressee_id,
+ p_type := 'friend_request_received',
+ p_title := 'Nueva solicitud de amistad',
+ p_body := COALESCE(v_name, 'Alguien') || ' quiere ser tu amigo en jugaMUS',
+ p_payload_json := jsonb_build_object('friendship_id', v_row.id, 'requester_id', v_self)
+ );
+
+ friendship_id := v_row.id;
+ status := v_row.status;
+ RETURN NEXT;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.send_friend_request(UUID, TEXT) TO authenticated;