# 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 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`: ```typescript 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`: ```typescript 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`: ```typescript 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: ```typescript signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> ``` ### Implementación en `SupabaseAuthService` ```typescript 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: ```typescript 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) ```typescript 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 `` con texto superpuesto o simplemente un `

` 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` ```typescript // 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 } ``` ```typescript // ResetPasswordPage.tsx const ResetPasswordPage = () => { const [password, setPassword] = useState('') const [confirm, setConfirm] = useState('') const [error, setError] = useState(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` ```typescript 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) ```typescript // 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