# 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= GOTRUE_EXTERNAL_GOOGLE_SECRET= GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https:///auth/v1/callback # URLs que deben coincidir entre GoTrue y la consola de Google OAuth API_EXTERNAL_URL=https:// SITE_URL=https:// ADDITIONAL_REDIRECT_URLS=https://,http://localhost:5173 ``` En Google Cloud Console: crear credenciales OAuth 2.0 tipo "Aplicación web", agregar `https:///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 { 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 }): 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 { 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( 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:// VITE_SUPABASE_ANON_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 } } ```