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