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

12 KiB

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:

# 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

-- 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

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

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".

// 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

// 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:

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)

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 existecreateClient() 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.

// 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

// 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

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)

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):

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