Ir al contenido

Set-password / primer acceso

/auth/set-password es la pantalla final de cualquier invite — staff o jugador, masivo o individual. Pide contraseña, la valida en el server (≥8 caracteres, no en lista de triviales), y si el invite era de un jugador, ejecuta linkPlayerAfterPasswordSet para escribir players.auth_user_id y onboarding_consumed_at de forma race-safe.

  • Problema que resuelve: activación de cualquier usuario con la misma URL y mismo flow, sin diferenciar tipos de invite.
  • Diferenciador: validación server-side (no client-side) — un usuario con DevTools no puede bypassear el length check ni la lista de passwords triviales.

Pública, pero requiere sesión válida (cookie creada por el invite link de Supabase Auth). Sin sesión activa, la action devuelve “Sesión expirada. Pedí un nuevo link de invitación.”

  1. Usuario abre el link del mail de invitación.
  2. El client _client.tsx escucha onAuthStateChange. Si llega por implicit flow (tokens en window.location.hash), llama setSession({ access_token, refresh_token }) manualmente porque @supabase/ssr no procesa hash tokens. Si llega por PKCE (?code=…), el client de Supabase auto-intercambia.
  3. La página /auth/set-password renderiza un formulario con dos inputs (contraseña + confirmar contraseña) y minLength=8 en el input.
  4. Submit hace primero un check client-side (length >= 8 y password === confirm) y luego dispara setPasswordAction(password):
    • Valida length >= 8 en server (defense in depth: bypaseable en client con DevTools).
    • Rechaza passwords triviales contra COMMON_PASSWORDS — lista corta (12345678, password, qwerty12, abc12345, etc.).
    • Exige sesión activa (auth.getUser() no null).
    • Llama supabase.auth.updateUser({ password }).
    • Si el invite era de jugador (metadata player_id), llama linkPlayerAfterPasswordSet():
      • Lee user_metadata.player_id (y opcionalmente organization_id) server-trusted desde la sesión, no de la URL.
      • Verifica que el player exista, que su organization_id coincida con la del metadata (si vino), que status no sea inactive, y que el slot esté libre.
      • Hace UPDATE players SET auth_user_id = user.id, onboarding_consumed_at = now() con WHERE auth_user_id IS NULL AND onboarding_consumed_at IS NULL — race-safe contra dos sesiones concurrentes con el mismo metadata.
      • Si el linkeo falla, no rompe la activación de la contraseña: queda fijada igual, Sentry registra warning.
  5. El client hace router.replace(next) con next sanitizado por sanitizeNextPath (bloquea //evil.com, esquemas no relativos, etc.). Default por invite link: /player para jugadores, /dashboard para staff.
  • Lista de passwords triviales en app/auth/set-password/actions.ts (COMMON_PASSWORDS). Es una lista corta — no zxcvbn — pero corta los abuses más obvios.
  • NEXT_PUBLIC_SITE_URL define el dominio absoluto del redirect.
  • Contraseña con menos de 8 caracteres: rechazada en server.
  • Contraseña en lista trivial: rechazada con “Elegí una contraseña menos común.”
  • Link expirado: Supabase Auth rebota la sesión. La action devuelve “Sesión expirada. Pedí un nuevo link de invitación.” El usuario tiene que pedir un invite nuevo al staff.
  • Linkeo de jugador falla: la contraseña queda fijada (no rompemos la activación), pero el auth_user_id no se asocia. Sentry registra un warning con el user.id y el metaPlayerId para que el cuerpo técnico pueda intervenir. La action devuelve igual { ok: true, linkedPlayerId: null } para que la UI no bloquee al usuario.
  • Ve dos inputs (contraseña + confirmar) + botón de submit.
  • Tras submit exitoso, queda logueado y redirigido a /player (default del invite del jugador). Si todavía no aceptó los consents obligatorios, /player/layout.tsx lo manda a /auth/consents antes de mostrarle el home.
  • Sin texto técnico: errores se muestran en castellano simple.
  • Labels e instrucciones vienen de messages/es.json namespace auth.setPassword.
  • Supabase Auth procesa el JWT del invite y persiste la password.
  • Tabla players se actualiza si el invite tenía player_id en metadata.
  • Sentry captura el linkeo fallido y los errores de auth.updateUser.
  • Bug anecdótico #10: algunos reportes de jugadores que llegan a /player sin quedar linkeados con auth_user_id. Sin repro reproducible — investigar cuando aparezca un caso concreto. Mientras tanto, el cuerpo técnico puede invitar de nuevo desde Plantel.
  • Sin “olvidé mi contraseña” en este path. Esa pantalla vive en /auth/reset-password.
  • Lista de passwords triviales es corta — para clientes con requisitos de compliance mayores, evaluar integrar zxcvbn o similar.