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.py → platform_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 vivabuildAuthUser,syncProfileFromDB,signInWithGoogle)src/features/auth/AuthContext.tsx(oAuthStore.tsx)src/domain/types.ts(para ver la interfaceAuthUseractual)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, notype="button"— para que Enter funcione- Mostrar error inline debajo del form si
signInWithEmailPasswordretorna 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
AuthUsertiene el campoorgId?: stringsyncProfileFromDBhace SELECT conplatform_role, org_id- Después del sync,
user.platformRolerefleja el valor de DB (no la heurística de email) signInWithEmailPasswordexiste en la capa de auth y funciona con SupabaseLoginPagemuestra el form email/password separado del botón Google/reset-passwordexiste como ruta pública, muestra el form de nueva contraseñaTOKEN_REFRESHEDenonAuthStateChangeno disparasetLoading(true)PASSWORD_RECOVERYenonAuthStateChangeredirige a/reset-passwordnpm run test→ 552+ tests verdes (más los nuevos de auth-role-sync)npm run buildlimpio
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