Files
inq-roi-simulador-web/sprints/sprint-7/ETAPA_3_PROMPT.md
Marcos Benítez bc9f70419c
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Resumen de lo entregado
2026-07-07 09:47:31 -03:00

9.6 KiB

Etapa 3 — Capa de auth: fix de rol desde DB + email/password + reset-password

Sprint: 7 Fecha: 2026-07-06 Alcance: capa de auth (frontend + router). Sin cambios en Edge Functions ni migraciones.


Contexto y problema a resolver

SupabaseAuthService.buildAuthUser() infiere el rol del email: @inquality.com.pyplatform_admin, cualquier otro → guest. Esto rompe los nuevos roles: un client_editor con email @banco-solar.com siempre sería guest y no podría acceder a nada.

El fix: syncProfileFromDB (o equivalente) debe leer platform_role y org_id desde public.users y actualizar el estado del usuario. La heurística del email puede sobrevivir como valor inicial optimista, pero el rol real viene de la DB.

ANTES de modificar cualquier archivo: leer el código actual de:

  • src/features/auth/SupabaseAuthService.ts (o donde viva buildAuthUser, syncProfileFromDB, signInWithGoogle)
  • src/features/auth/AuthContext.tsx (o AuthStore.tsx)
  • src/domain/types.ts (para ver la interface AuthUser actual)
  • src/router.tsx (para entender el patrón de rutas existente)

Adaptar el fix según lo que realmente existe — los nombres de métodos y archivos pueden diferir del BRIEF.


3a — Fix crítico: leer platform_role y org_id desde DB

Cambios en src/domain/types.ts

Extender AuthUser para incluir orgId:

export interface AuthUser {
  id: string
  email: string
  name: string
  avatarUrl?: string
  platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
  company?: string
  orgId?: string   // uuid de la organización del usuario — null para usuarios sin org
}

Si AuthUser ya existe con campos distintos, no eliminar campos existentes — solo agregar orgId.

Cambios en SupabaseAuthService (o donde viva syncProfileFromDB)

Extender el SELECT para incluir platform_role y org_id:

const { data } = await supabase
  .from('users')
  .select('name, company, platform_role, org_id')
  .eq('id', userId)
  .maybeSingle()

if (data) {
  // Retornar o despachar con los nuevos campos
  return {
    name: data.name,
    company: data.company ?? undefined,
    platformRole: data.platform_role as AuthUser['platformRole'],
    orgId: data.org_id ?? undefined,
  }
}

Cambios en AuthContext (o donde se llame syncProfileFromDB)

Cuando se recibe el resultado de syncProfileFromDB, actualizar TODOS los campos incluyendo platformRole y orgId:

setUser(prev => prev
  ? {
      ...prev,
      name: profile.name || prev.name,
      company: profile.company,
      platformRole: profile.platformRole,   // ← reemplaza la heurística de email
      orgId: profile.orgId,
    }
  : prev
)

Mantener heurística de email como valor inicial (optimista)

buildAuthUser() puede seguir usando el email para un valor inicial mientras se carga el perfil de DB. El syncProfileFromDB lo corrige después. Esto evita el flash de "sin acceso" durante la carga inicial.


3b — Agregar signInWithEmailPassword a la capa de auth

Interface AuthService (o equivalente)

Agregar método:

signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>

Implementación en SupabaseAuthService

async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> {
  const { error } = await supabase.auth.signInWithPassword({ email, password })
  return { error: error?.message ?? null }
}

Exponer en el contexto de auth

Si hay un hook useAuth() o similar, agregar signInWithEmailPassword al objeto retornado. Si no, exponerlo directamente desde el contexto.


3c — Guard TOKEN_REFRESHED en onAuthStateChange

Modificar el listener de onAuthStateChange para no disparar loading innecesariamente en TOKEN_REFRESHED:

supabase.auth.onAuthStateChange(async (event, session) => {
  if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') {
    // No cambiar loading ni disparar sync — la sesión ya está activa
    return
  }

  if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
    // ... lógica de login existente
  } else if (event === 'SIGNED_OUT') {
    setUser(null)
    setLoading(false)
  }
})

Nota: este fix puede resolver el bug de "workspace loading — se queda cargando sin refresh" reportado en Sprint 5 si ese bug se manifestaba al volver a la tab. Verificar si es el caso después de aplicar.


3d — LoginPage: agregar form email/password

La LoginPage actual solo tiene el botón "Continuar con Google". Agregar un formulario debajo:

Layout

[  Continuar con Google  ]

─────── o ─────────────────

[  Email                  ]
[  Contraseña             ]
[  Ingresar               ]

Hint: Los usuarios externos acceden por invitación.
      Si no recibiste uno, contactá a tu consultor InQuality.

Validación (Zod)

const loginSchema = z.object({
  email: z.string().email('Email inválido'),
  password: z.string().min(1, 'La contraseña es requerida'),
})

Comportamiento

  • type="submit" en el form, no type="button" — para que Enter funcione
  • Mostrar error inline debajo del form si signInWithEmailPassword retorna error (no toast, no alert)
  • Spinner o estado disabled en el botón mientras se procesa
  • En caso de error: el mensaje de Supabase en inglés es aceptable, o traducir si es simple ("Invalid login credentials" → "Email o contraseña incorrectos")
  • No registrar usuarios nuevos — solo login. Sin link "Crear cuenta".

Estilo

Usar los componentes shadcn/ui existentes: Input, Button, Label. El separador "o" puede ser un <Separator /> con texto superpuesto o simplemente un <p> centrado con estilo. No introducir librerías nuevas.


3e — Página /reset-password (set-password post-invite)

Cuando un usuario acepta el magic link de invitación, Supabase redirige a {SITE_URL}/auth/callback y luego a una URL con access_token en el hash. La app debe capturar esto y llevarlo a una página donde el usuario setea su contraseña.

Nueva ruta

Agregar /reset-password en src/router.tsx. Esta ruta es pública (no requiere sesión activa).

Nuevo componente src/features/auth/ResetPasswordPage.tsx

// Leer el token del hash de la URL
// Supabase lo procesa automáticamente vía onAuthStateChange con event = PASSWORD_RECOVERY

// El flujo:
// 1. El usuario hace click en el link del email
// 2. Supabase procesa el token → onAuthStateChange dispara PASSWORD_RECOVERY con sesión
// 3. La app detecta PASSWORD_RECOVERY y redirige a /reset-password
// 4. ResetPasswordPage muestra el form de nueva contraseña

// En onAuthStateChange (en AuthContext):
if (event === 'PASSWORD_RECOVERY') {
  navigate({ to: '/reset-password' })
  return
}
// ResetPasswordPage.tsx
const ResetPasswordPage = () => {
  const [password, setPassword] = useState('')
  const [confirm, setConfirm] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(false)
  const navigate = useNavigate()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (password !== confirm) {
      setError('Las contraseñas no coinciden')
      return
    }
    if (password.length < 8) {
      setError('La contraseña debe tener al menos 8 caracteres')
      return
    }
    setLoading(true)
    const { error } = await supabase.auth.updateUser({ password })
    if (error) {
      setError(error.message)
      setLoading(false)
    } else {
      navigate({ to: '/' })
    }
  }

  return (
    // Form con dos campos: nueva contraseña + confirmar, botón "Establecer contraseña"
    // Misma estructura visual que LoginPage
  )
}

Agregar PASSWORD_RECOVERY al guard de onAuthStateChange

if (event === 'PASSWORD_RECOVERY') {
  // Sesión activa pero el usuario necesita setear contraseña
  // Redirigir a /reset-password sin setear el user en estado
  navigate({ to: '/reset-password' })
  return
}

Tests nuevos (mínimo requerido)

// tests/features/auth/auth-role-sync.test.ts
// - buildAuthUser con email @inquality.com.py retorna platformRole optimista
// - syncProfileFromDB con { platform_role: 'client_editor' } actualiza el estado
// - syncProfileFromDB con { platform_role: 'platform_admin' } (DB) sobrescribe heurística de email
// - AuthUser con orgId seteado retiene el campo tras sync

// NO requiere tests E2E en esta etapa — el login real se prueba manualmente
// con un usuario invitado en producción

Criterios de validación

  • AuthUser tiene el campo orgId?: string
  • syncProfileFromDB hace SELECT con platform_role, org_id
  • Después del sync, user.platformRole refleja el valor de DB (no la heurística de email)
  • signInWithEmailPassword existe en la capa de auth y funciona con Supabase
  • LoginPage muestra el form email/password separado del botón Google
  • /reset-password existe como ruta pública, muestra el form de nueva contraseña
  • TOKEN_REFRESHED en onAuthStateChange no dispara setLoading(true)
  • PASSWORD_RECOVERY en onAuthStateChange redirige a /reset-password
  • npm run test → 552+ tests verdes (más los nuevos de auth-role-sync)
  • npm run build limpio

NO modificar

  • Edge Functions (supabase/functions/)
  • Archivos de migración (001-015)
  • WorkspacePage, ReportPage, LibraryPage — los cambios de UI de roles van en Etapa 5
  • Panel de admin — va en Etapa 4
  • Lógica de simulación, dominio, y cálculo de ROI