feat(sprint-4/etapa-1): Supabase auth + Google OAuth + LoginPage + rutas protegidas

- Cliente Supabase singleton en src/lib/supabase.ts
- Interfaces AuthUser / AuthService en src/auth/types.ts
- SupabaseAuthService: signInWithGoogle, signOut, getCurrentUser, onAuthStateChange
  con upsert a tabla users (platform_admin para @inquality.com.py, guest resto)
- AuthProvider (React Context) wrappea toda la app; expone user, loading, signIn, signOut
- LoginPage /login: logo InQuality color, título, fade-in, botón Google naranja #F59845
- RootComponent del router guarda todas las rutas — sin sesión → /login, con sesión + /login → /
- Script SQL: supabase/migrations/001_create_users.sql (tabla users + RLS)
- AppHeader: <Link> → <a href="/"> para evitar dependencia de RouterContext en tests
- 561/561 tests verdes, build sin errores TS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:53:12 -03:00
parent 2c57bd5bf4
commit 78e776df6b
14 changed files with 1089 additions and 18 deletions

46
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
import { SupabaseAuthService } from './SupabaseAuthService'
import type { AuthUser } from './types'
interface AuthContextValue {
user: AuthUser | null
loading: boolean
signIn: () => Promise<void>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
const authService = new SupabaseAuthService()
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const unsubscribe = authService.onAuthStateChange((u) => {
setUser(u)
setLoading(false)
})
return unsubscribe
}, [])
const signIn = useCallback(() => authService.signInWithGoogle(), [])
const signOut = useCallback(async () => {
await authService.signOut()
setUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, loading, signIn, signOut }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider')
return ctx
}

View File

@@ -0,0 +1,70 @@
import { supabase } from '@/lib/supabase'
import type { AuthService, AuthUser } from './types'
export class SupabaseAuthService implements AuthService {
async signInWithGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: window.location.origin },
})
if (error) throw error
}
async signOut(): Promise<void> {
const { error } = await supabase.auth.signOut()
if (error) throw error
}
async getCurrentUser(): Promise<AuthUser | null> {
const { data: { session } } = await supabase.auth.getSession()
if (!session?.user) return null
return this.buildAuthUser(session.user)
}
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (!session?.user) {
callback(null)
return
}
if (event === 'SIGNED_IN') {
const email = session.user.email ?? ''
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
await supabase.from('users').upsert(
{
id: session.user.id,
name: session.user.user_metadata?.full_name ?? email,
avatar_url: session.user.user_metadata?.avatar_url ?? null,
platform_role: role,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
}
const user = await this.buildAuthUser(session.user)
callback(user)
}
)
return () => subscription.unsubscribe()
}
private async buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): Promise<AuthUser> {
const email = supabaseUser.email ?? ''
const { data: row } = await supabase
.from('users')
.select('name, avatar_url, platform_role')
.eq('id', supabaseUser.id)
.single()
return {
id: supabaseUser.id,
email,
name: row?.name ?? supabaseUser.user_metadata?.full_name as string ?? email,
avatarUrl: row?.avatar_url ?? supabaseUser.user_metadata?.avatar_url as string ?? undefined,
platformRole: (row?.platform_role ?? 'guest') as AuthUser['platformRole'],
}
}
}

14
src/auth/types.ts Normal file
View File

@@ -0,0 +1,14 @@
export interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'guest'
}
export interface AuthService {
signInWithGoogle(): Promise<void>
signOut(): Promise<void>
getCurrentUser(): Promise<AuthUser | null>
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
}