This commit is contained in:
293
sprints/sprint-7/ETAPA_3_PROMPT.md
Normal file
293
sprints/sprint-7/ETAPA_3_PROMPT.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# 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 `<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`
|
||||
|
||||
```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<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`
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user