Files
inq-roi-simulador-web/sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md
2026-07-06 18:52:24 -03:00

346 lines
12 KiB
Markdown

# 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
}
}
```