docs(sprint-6): cierre formal — observaciones Etapas 1-3 + reflexiones estratégicas
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
242
sprints/sprint-6/ETAPA_3_PROMPT.md
Normal file
242
sprints/sprint-6/ETAPA_3_PROMPT.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Etapa 3 — Limpieza técnica y diagnóstico de auth
|
||||
|
||||
**Sprint:** 6
|
||||
**Fecha:** 2026-06-24
|
||||
**Alcance:** fix confirmado de localStorage key en ProfileSheet, eliminación de setTimeout(350), spec E2E de auth, actualización de TECH_DEBT.md.
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
Esta etapa cierra deuda técnica documentada y resuelve una contradicción activa en el manejo de auth que podría ser un bug real de persistencia de perfil.
|
||||
|
||||
---
|
||||
|
||||
## Parte A — Bug potencial: localStorage key en ProfileSheet
|
||||
|
||||
### El problema
|
||||
|
||||
`ProfileSheet.getAccessToken()` lee de la clave `'supabase.auth.token'`:
|
||||
|
||||
```ts
|
||||
// ProfileSheet.tsx línea 14-23
|
||||
function getAccessToken(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('supabase.auth.token') // ← clave potencialmente incorrecta
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pero `auth-setup.ts` documenta y confirma empíricamente que `@supabase/supabase-js v2`'s `createClient()` escribe la sesión bajo `sb-${projectRef}-auth-token`, no bajo `supabase.auth.token`. El `projectRef` se deriva de la URL del proyecto.
|
||||
|
||||
Si en producción la sesión está bajo `sb-${projectRef}-auth-token`, entonces `localStorage.getItem('supabase.auth.token')` retorna `null` → `getAccessToken()` retorna `null` → `if (!token) return` en línea 67 silencia el error → **el persist a Supabase de nombre y empresa nunca se ejecuta**. El usuario ve el cambio en UI (vía `updateUser()` en memoria) pero no se persiste.
|
||||
|
||||
### Diagnóstico requerido
|
||||
|
||||
Antes de modificar código, confirmar el estado real con un spec o con `console.log` en dev:
|
||||
|
||||
**Opción A (spec):** agregar un spec E2E `tests/e2e/auth-localStorage-key.spec.ts` que:
|
||||
1. Navega con `storageState` de auth
|
||||
2. Evalúa `document.cookie` o `localStorage` en el contexto del browser
|
||||
3. Afirma qué clave de auth existe realmente (`supabase.auth.token` vs `sb-${projectRef}-auth-token`)
|
||||
4. Afirma que la clave correcta contiene un `access_token` válido (string no vacío)
|
||||
|
||||
**Fix según diagnóstico:**
|
||||
|
||||
Si la clave real es `sb-${projectRef}-auth-token` (probable):
|
||||
|
||||
```ts
|
||||
// Reemplazar getAccessToken() completa:
|
||||
function getAccessToken(): string | null {
|
||||
try {
|
||||
// supabase-js v2 calcula la clave como sb-{projectRef}-auth-token
|
||||
// donde projectRef = hostname.split('.')[0] de la URL del proyecto.
|
||||
const projectRef = new URL(import.meta.env.VITE_SUPABASE_URL as string).hostname.split('.')[0]
|
||||
const storageKey = `sb-${projectRef}-auth-token`
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as { access_token?: string }
|
||||
return parsed.access_token ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Si en producción ambas claves coexisten (posible si hay versiones mixtas de @supabase/auth-js), leer con fallback:
|
||||
```ts
|
||||
const raw = localStorage.getItem(storageKey) ?? localStorage.getItem('supabase.auth.token')
|
||||
```
|
||||
|
||||
**Actualizar el comentario** en `ProfileSheet.tsx` para reflejar la clave real y el diagnóstico confirmado. El comentario actual dice "STORAGE_KEY = 'supabase.auth.token'" — eso era la documentación de auth-js v1/v2-old, no de supabase-js v2.
|
||||
|
||||
### Criterios de Parte A
|
||||
|
||||
- [ ] Spec E2E confirma qué clave existe en localStorage en producción bajo auth real
|
||||
- [ ] `getAccessToken()` lee la clave correcta
|
||||
- [ ] Comentario en ProfileSheet.tsx refleja la clave real y la razón
|
||||
- [ ] El save de perfil (nombre, empresa) persiste en Supabase — verificar manualmente en Studio o via spec
|
||||
|
||||
---
|
||||
|
||||
## Parte B — Eliminar setTimeout(350) de ProfileSheet
|
||||
|
||||
### El problema
|
||||
|
||||
`ProfileSheet.handleSave()` difiere `updateUser()` 350ms:
|
||||
|
||||
```ts
|
||||
// ProfileSheet.tsx líneas 57-60
|
||||
onOpenChange(false)
|
||||
setTimeout(() => {
|
||||
updateUser({ name: savedName, company: savedCompany })
|
||||
}, 350)
|
||||
```
|
||||
|
||||
El comentario dice que es para evitar que `@radix-ui/react-remove-scroll` deje `pointer-events:none` permanente en el body. Pero `src/components/ui/sheet.tsx` ya tiene el fix definitivo (useEffect sobre prop `open` que restaura pointer-events defensivamente). El setTimeout es redundante.
|
||||
|
||||
### Fix
|
||||
|
||||
1. Reemplazar el bloque con llamada directa:
|
||||
|
||||
```ts
|
||||
onOpenChange(false)
|
||||
updateUser({ name: savedName, company: savedCompany })
|
||||
```
|
||||
|
||||
2. Verificar manualmente que después de guardar el perfil, la UI responde (no hay pointer-events:none atascado en body). Si el fix de sheet.tsx es suficiente, el comportamiento es idéntico. Si reaparece el bug, restaurar el setTimeout y documentar que el fix de sheet.tsx no es suficiente solo.
|
||||
|
||||
3. Actualizar el comentario para explicar que el fix vive en sheet.tsx.
|
||||
|
||||
### Criterios de Parte B
|
||||
|
||||
- [ ] setTimeout(350) eliminado de ProfileSheet.tsx
|
||||
- [ ] Verificación manual: guardar perfil → UI responde correctamente, sin pointer-events bloqueados
|
||||
- [ ] Comentario actualizado explicando que el fix está en sheet.tsx
|
||||
|
||||
---
|
||||
|
||||
## Parte C — Spec E2E de validación de auth real
|
||||
|
||||
Independientemente de la resolución de Parte A, agregar un spec que valide que el auth E2E funciona realmente (no solo que el storageState se carga, sino que Supabase devuelve datos reales del usuario).
|
||||
|
||||
**Archivo:** `tests/e2e/auth-real.spec.ts`
|
||||
|
||||
```ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
|
||||
|
||||
test('auth E2E: Supabase devuelve datos reales del usuario autenticado', async ({ page }) => {
|
||||
// Navegar a la app autenticada
|
||||
await page.goto('http://localhost:4173/')
|
||||
|
||||
// Esperar a que AppHeader muestre el avatar/nombre (confirma que AuthContext cargó la sesión)
|
||||
await expect(page.getByTestId('app-header-user-avatar')).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Verificar que el email del usuario de test aparece en el dropdown de perfil
|
||||
// (o bien hacer una query directa a Supabase REST para confirmar que el token es válido)
|
||||
const email = process.env.TEST_USER_EMAIL
|
||||
if (email) {
|
||||
await page.getByTestId('app-header-user-avatar').click()
|
||||
await expect(page.getByText(email)).toBeVisible()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Adaptar según los `data-testid` reales del AppHeader (consultarlos antes de escribir el spec).
|
||||
|
||||
Si `TEST_USER_EMAIL` no está en `.env.test`, el spec hace skip con mensaje descriptivo (mismo patrón de `export-pdf.spec.ts`).
|
||||
|
||||
### Criterios de Parte C
|
||||
|
||||
- [ ] Spec en `tests/e2e/auth-real.spec.ts` que pasa cuando auth funciona
|
||||
- [ ] Spec hace `test.skip()` descriptivo si `TEST_USER_EMAIL` no está configurado
|
||||
- [ ] Si el spec falla, el diagnóstico es definitivo: el auth E2E no funciona realmente → fix en auth-setup.ts o en AuthContext
|
||||
|
||||
---
|
||||
|
||||
## Parte D — Actualización de TECH_DEBT.md
|
||||
|
||||
Al terminar A, B y C, actualizar TECH_DEBT.md:
|
||||
|
||||
1. Marcar `[INVESTIGATING] Clave de localStorage del auth E2E vs producción` como `[RESOLVED]` con el resultado real del diagnóstico.
|
||||
|
||||
2. Marcar `[BACKLOG] setTimeout(350) en ProfileSheet` como `[RESOLVED]`.
|
||||
|
||||
3. Agregar nuevo ítem:
|
||||
|
||||
```markdown
|
||||
### [BACKLOG] CumulativeSavingsChart — derivación de inversión base desde cumulativeSavingsHorizon
|
||||
**Estado actual:** cuando `roiBase` está presente (modo sensibilidad activo), el componente
|
||||
recupera la inversión original algebraicamente: `investment_base = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon`.
|
||||
Es correcto mientras `horizonYears` del chart coincida con el usado en `calculateRoi` (hoy siempre es el mismo).
|
||||
**Riesgo:** si en el futuro el chart recibe un horizonte diferente al del cálculo de ROI, el valor
|
||||
de referencia del eje Y para la línea base será incorrecto, sin error visible.
|
||||
**Solución propuesta:** agregar prop `investmentBase?: number` a `CumulativeSavingsChartProps`
|
||||
y pasar `process.automationInvestment` directamente desde `ReportPage`.
|
||||
**Costo:** bajo. **Origen:** Sprint 6 Etapa 2 — detectado en auditoría post-implementación.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parte E — Opcional: animación de expansión en ActivityPanel
|
||||
|
||||
Solo implementar si las partes A-D no consumen el slot completo.
|
||||
|
||||
La sección de automatización en `ActivityPanel` usa `max-height` para expandir/colapsar. La aceleración no es lineal en los últimos píxeles (se nota el "freno" al final).
|
||||
|
||||
Fix: usar `useRef + scrollHeight` para animar a la altura real del contenido:
|
||||
|
||||
```tsx
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
// Al expandir:
|
||||
contentRef.current.style.maxHeight = contentRef.current.scrollHeight + 'px'
|
||||
// Al colapsar:
|
||||
contentRef.current.style.maxHeight = '0'
|
||||
```
|
||||
|
||||
Con transición CSS `transition-[max-height] duration-300 ease-in-out`.
|
||||
|
||||
Verificar visualmente antes de commitear.
|
||||
|
||||
---
|
||||
|
||||
## Orden de ejecución recomendado
|
||||
|
||||
1. Parte A (diagnóstico y fix de localStorage key) — puede revelar un bug real, prioridad más alta
|
||||
2. Parte B (setTimeout cleanup) — depende de confirmar que sheet.tsx fix es suficiente
|
||||
3. Parte C (spec E2E) — complementa Parte A
|
||||
4. Parte D (TECH_DEBT.md) — al final, cuando A-C estén resueltos
|
||||
5. Parte E (animación) — solo si hay tiempo
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación del conjunto
|
||||
|
||||
- [ ] `getAccessToken()` en ProfileSheet.tsx usa la clave correcta de localStorage
|
||||
- [ ] Save de perfil persiste en Supabase (nombre y empresa visibles al recargar)
|
||||
- [ ] setTimeout(350) eliminado, UI funciona correctamente después de guardar perfil
|
||||
- [ ] Spec E2E de auth pasa o hace skip descriptivo
|
||||
- [ ] TECH_DEBT.md actualizado: ítems resueltos marcados, nueva nota de CumulativeSavingsChart
|
||||
- [ ] `npm run lint` sin errores
|
||||
- [ ] `npm run build` limpio
|
||||
- [ ] `npm run test` → 552+ tests verdes
|
||||
- [ ] Validación del director sobre el estado de la documentación antes del OK formal
|
||||
|
||||
---
|
||||
|
||||
## Archivos a modificar
|
||||
|
||||
- `src/components/ProfileSheet.tsx` — clave localStorage + eliminar setTimeout
|
||||
- `tests/e2e/auth-localStorage-key.spec.ts` — spec de diagnóstico (nuevo)
|
||||
- `tests/e2e/auth-real.spec.ts` — spec de validación E2E (nuevo)
|
||||
- `TECH_DEBT.md` — marcar resueltos + agregar nota CumulativeSavingsChart
|
||||
|
||||
**Opcional:**
|
||||
- `src/features/workspace/ActivityPanel.tsx` — animación scrollHeight
|
||||
|
||||
**No modificar:**
|
||||
- `tests/e2e/setup/auth-setup.ts` — ya documentado correctamente
|
||||
- Ningún otro archivo fuera de esta lista sin consulta previa
|
||||
345
sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md
Normal file
345
sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Referencia: Google OAuth con Supabase para InQ Sizing
|
||||
|
||||
> Extraído de la implementación de InQ ROI (Stack: Vite + React 18 + TS + Supabase cloud).
|
||||
> InQ Sizing usa Supabase **self-hosted en Dokploy** — las variables de GoTrue son equivalentes al dashboard cloud.
|
||||
|
||||
---
|
||||
|
||||
## 1. Configuración de GoTrue para Google OAuth (self-hosted)
|
||||
|
||||
InQ ROI usa Supabase cloud, donde esto se configura en el dashboard.
|
||||
Para Supabase self-hosted en Dokploy, las variables de entorno equivalentes son:
|
||||
|
||||
```env
|
||||
# En el servicio GoTrue (supabase-auth)
|
||||
GOTRUE_EXTERNAL_GOOGLE_ENABLED=true
|
||||
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=<client_id_de_google_cloud_console>
|
||||
GOTRUE_EXTERNAL_GOOGLE_SECRET=<client_secret>
|
||||
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://<tu-dominio-supabase>/auth/v1/callback
|
||||
|
||||
# URLs que deben coincidir entre GoTrue y la consola de Google OAuth
|
||||
API_EXTERNAL_URL=https://<tu-dominio-supabase>
|
||||
SITE_URL=https://<tu-app>
|
||||
ADDITIONAL_REDIRECT_URLS=https://<tu-app>,http://localhost:5173
|
||||
```
|
||||
|
||||
En Google Cloud Console: crear credenciales OAuth 2.0 tipo "Aplicación web",
|
||||
agregar `https://<tu-dominio-supabase>/auth/v1/callback` como "URI de redirección autorizado".
|
||||
|
||||
---
|
||||
|
||||
## 2. Tabla `public.users` — estructura de InQ ROI
|
||||
|
||||
```sql
|
||||
-- supabase/migrations/001_create_users.sql
|
||||
CREATE TABLE IF NOT EXISTS public.users (
|
||||
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name text,
|
||||
avatar_url text,
|
||||
platform_role text NOT NULL DEFAULT 'guest'
|
||||
CHECK (platform_role IN ('platform_admin', 'member', 'guest')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Cualquier usuario autenticado puede leer la tabla users
|
||||
CREATE POLICY "read_users" ON public.users FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||
-- Solo puede actualizar su propia fila
|
||||
CREATE POLICY "update_own_user" ON public.users FOR UPDATE USING (id = auth.uid());
|
||||
-- El propio usuario puede insertar su fila (habilita el upsert application-side)
|
||||
CREATE POLICY "insert_users" ON public.users FOR INSERT WITH CHECK (id = auth.uid());
|
||||
```
|
||||
|
||||
**Nota clave:** la política `insert_users` permite que el propio usuario inserte su fila.
|
||||
Esto es lo que habilita el upsert desde la aplicación sin necesidad de un trigger SQL.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cómo se crea la fila en `public.users` al primer login
|
||||
|
||||
InQ ROI resuelve esto **a nivel de aplicación**, no con un trigger SQL.
|
||||
|
||||
### 3a. `upsertUserOnLogin()` en `SupabaseAuthService.ts`
|
||||
|
||||
```ts
|
||||
async upsertUserOnLogin(user: AuthUser, signal?: AbortSignal): Promise<void> {
|
||||
const builder = supabase.from('users').upsert(
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatar_url: user.avatarUrl ?? null,
|
||||
platform_role: user.platformRole, // derivado del email, ver sección 4
|
||||
},
|
||||
{ onConflict: 'id', ignoreDuplicates: true } // no sobreescribe si ya existe
|
||||
)
|
||||
const { error } = await (signal ? builder.abortSignal(signal) : builder)
|
||||
if (error) console.error('Error al crear usuario en DB:', error)
|
||||
}
|
||||
```
|
||||
|
||||
### 3b. Llamada desde `AuthContext.tsx` — FUERA del lock de auth
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
const userId = user.id
|
||||
const capturedUser = user
|
||||
|
||||
// 6s timeout: cancela si el servidor no responde, libera el processLock
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
// Upsert idempotente: crea la fila si no existe (primer login con Google)
|
||||
await authService.upsertUserOnLogin(capturedUser, controller.signal)
|
||||
// Leer company y name desde DB (pueden diferir del user_metadata de Google)
|
||||
const profile = await authService.syncProfileFromDB(userId, controller.signal)
|
||||
if (profile) {
|
||||
setUser((prev) =>
|
||||
prev?.id === userId
|
||||
? { ...prev, company: profile.company, name: profile.name || prev.name }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
console.error('Error al sincronizar perfil:', err)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [user?.id]) // dep primitiva: no cambia cuando el usuario edita nombre/empresa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Cómo se determina el rol — derivación desde el email
|
||||
|
||||
Esta es la decisión arquitectónica central de InQ ROI: **el rol es determinista a partir del dominio del email**, sin asignación manual ni pantalla de "esperando aprobación".
|
||||
|
||||
```ts
|
||||
// En buildAuthUser() — síncrono, sin consultar DB
|
||||
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
|
||||
const email = supabaseUser.email ?? ''
|
||||
return {
|
||||
id: supabaseUser.id,
|
||||
email,
|
||||
name: (supabaseUser.user_metadata?.full_name as string | undefined) || email,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined,
|
||||
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
El upsert del primer login ya graba el rol correcto desde el inicio.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend — `signInWithOAuth`
|
||||
|
||||
```ts
|
||||
// src/auth/SupabaseAuthService.ts
|
||||
async signInWithGoogle(): Promise<void> {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: window.location.origin,
|
||||
queryParams: {
|
||||
prompt: 'select_account', // fuerza el selector aunque haya sesión activa
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Manejo del redirect OAuth — evitar loop /login
|
||||
|
||||
Cuando Google redirige de vuelta a la app, la URL tiene `#access_token=...` o `?code=...`.
|
||||
Si el contexto lee localStorage antes de que Supabase procese ese hash, puede redirigir
|
||||
a `/login` causando un flash o loop. InQ ROI lo previene con esta lógica en `AuthContext`:
|
||||
|
||||
```ts
|
||||
function hasOAuthCallback(): boolean {
|
||||
const hash = window.location.hash
|
||||
const search = window.location.search
|
||||
return (
|
||||
hash.includes('access_token') ||
|
||||
hash.includes('refresh_token') ||
|
||||
search.includes('code=')
|
||||
)
|
||||
}
|
||||
|
||||
// En useState inicial:
|
||||
const isOAuthRedirect = hasOAuthCallback()
|
||||
const [user, setUser] = useState<AuthUser | null>(
|
||||
isOAuthRedirect ? null : getUserFromStorage() // no leer storage durante el callback
|
||||
)
|
||||
const [loading, setLoading] = useState(isOAuthRedirect) // loading=true hasta que onAuthStateChange dispare
|
||||
```
|
||||
|
||||
### Lectura de localStorage sin red (para arranque inmediato)
|
||||
|
||||
```ts
|
||||
function getUserFromStorage(): AuthUser | null {
|
||||
try {
|
||||
const key = Object.keys(localStorage).find(
|
||||
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
|
||||
)
|
||||
if (!key) return null
|
||||
const stored = JSON.parse(localStorage.getItem(key) ?? 'null')
|
||||
const u = stored?.user
|
||||
if (!u?.id) return null
|
||||
const email = (u.email as string) ?? ''
|
||||
return {
|
||||
id: u.id as string,
|
||||
email,
|
||||
name: (u.user_metadata?.full_name as string | undefined) || email,
|
||||
avatarUrl: u.user_metadata?.avatar_url as string | undefined,
|
||||
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nota:** La clave en localStorage es `sb-${projectRef}-auth-token` (donde `projectRef` es
|
||||
el hostname del `SUPABASE_URL` sin dominio). La clave `supabase.auth.token` que documenta
|
||||
`@supabase/auth-js` en aislamiento **no existe** — `createClient()` de `@supabase/supabase-js`
|
||||
la sobreescribe con la clave prefijada `sb-`. Confirmado por diagnóstico E2E en Sprint 6 Etapa 3.
|
||||
|
||||
---
|
||||
|
||||
## 7. Restricción crítica: `onAuthStateChange` callback DEBE ser síncrono
|
||||
|
||||
El callback de `onAuthStateChange` se ejecuta mientras GoTrueClient mantiene su lock interno activo.
|
||||
Hacer `await` de cualquier operación Supabase dentro de ese callback causa un deadlock permanente.
|
||||
|
||||
```ts
|
||||
// MAL — deadlock garantizado:
|
||||
supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
await supabase.from('profiles').select(...) // bloquea el processLock para siempre
|
||||
})
|
||||
|
||||
// BIEN — síncrono, queries afuera del lock:
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
// Solo datos del token en memoria, sin queries
|
||||
callback(this.buildAuthUser(session.user))
|
||||
})
|
||||
// Las queries a profiles van en useEffect([user?.id]) fuera del lock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Configuración del cliente Supabase — processLock
|
||||
|
||||
```ts
|
||||
// src/lib/supabase.ts
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
// processLock usa coordinación solo en memoria (sin navigator.locks).
|
||||
// Evita deadlocks entre tabs a costa de no sincronizar refresh entre tabs
|
||||
// (aceptable para producto mayormente single-tab).
|
||||
lock: processLock,
|
||||
// 30s en lugar del default de 5s: margen para refresh en redes lentas / cold starts.
|
||||
lockAcquireTimeout: 30000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Variables de entorno del frontend
|
||||
|
||||
```env
|
||||
VITE_SUPABASE_URL=https://<tu-dominio-supabase>
|
||||
VITE_SUPABASE_ANON_KEY=<anon-public-key>
|
||||
```
|
||||
|
||||
No hay configuración especial en el cliente para Google OAuth.
|
||||
`signInWithOAuth` funciona con el cliente estándar una vez que GoTrue tiene habilitado el proveedor.
|
||||
|
||||
---
|
||||
|
||||
## 10. La pregunta crítica para InQ Sizing: el problema del rol vacío
|
||||
|
||||
InQ ROI tiene 2 roles y la regla es trivial (dominio del email). InQ Sizing tiene 4 roles
|
||||
(`admin|comercial|cliente_coordinador|cliente_gerente`) donde el rol define visibilidad
|
||||
de datos via RLS — **no hay forma de derivarlo del email automáticamente**.
|
||||
|
||||
### Opción A: Trigger SQL en `auth.users` (recomendada cuando rol vacío rompe RLS)
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.profiles (id, organization_id, full_name, role)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NULL, -- admin asigna org después
|
||||
NEW.raw_user_meta_data->>'full_name',
|
||||
'pendiente' -- rol especial hasta asignación manual
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||
```
|
||||
|
||||
La fila existe siempre desde el primer login, incluso antes de que la app se cargue.
|
||||
El admin asigna `role` + `organization_id` después. RLS puede permitir que `role = 'pendiente'`
|
||||
acceda solo a una pantalla de "acceso pendiente" en lugar de romper totalmente.
|
||||
|
||||
### Opción B: Upsert application-side (igual que InQ ROI) + rol default `'pendiente'`
|
||||
|
||||
Misma estructura que InQ ROI pero con `role: 'pendiente'` en el upsert.
|
||||
Riesgo: ventana de tiempo entre el login y el `useEffect` donde RLS puede romper
|
||||
las primeras queries (devuelven vacío o error silencioso).
|
||||
|
||||
### Comparativa
|
||||
|
||||
| Aspecto | InQ ROI | InQ Sizing (a decidir) |
|
||||
|---|---|---|
|
||||
| Roles | 2, derivados de email | 4, no derivables de email |
|
||||
| Creación de fila en profiles | Upsert app-side en `useEffect` | Recomiendo trigger SQL en `auth.users` |
|
||||
| Rol en primer login | Inmediato y determinista | Necesita flujo "pendiente" + asignación manual por admin |
|
||||
| RLS sin fila existente | Queries devuelven vacío (aceptable) | Probablemente rompe — evaluar antes de elegir opción |
|
||||
|
||||
---
|
||||
|
||||
## 11. Clave de localStorage — diagnóstico
|
||||
|
||||
Si necesitan leer el token directamente (como InQ ROI en `ProfileSheet.getAccessToken()`
|
||||
para evitar contención con processLock al guardar perfil):
|
||||
|
||||
```ts
|
||||
function getAccessToken(): string | null {
|
||||
try {
|
||||
// @supabase/supabase-js createClient() calcula la clave como sb-{projectRef}-auth-token
|
||||
// donde projectRef = hostname de SUPABASE_URL sin dominio.
|
||||
// La clave 'supabase.auth.token' de @supabase/auth-js en aislamiento NO existe.
|
||||
const projectRef = new URL(SUPABASE_URL).hostname.split('.')[0]
|
||||
const storageKey = `sb-${projectRef}-auth-token`
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as { access_token?: string }
|
||||
return parsed.access_token ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user