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:
@@ -1,13 +1,14 @@
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { AuthProvider } from '@/auth/AuthContext'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
46
src/auth/AuthContext.tsx
Normal file
46
src/auth/AuthContext.tsx
Normal 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
|
||||
}
|
||||
70
src/auth/SupabaseAuthService.ts
Normal file
70
src/auth/SupabaseAuthService.ts
Normal 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
14
src/auth/types.ts
Normal 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
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function AppHeader() {
|
||||
return (
|
||||
<header
|
||||
@@ -14,15 +12,15 @@ export function AppHeader() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<a href="/" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img
|
||||
src="/inquality-logo-white.png"
|
||||
alt="InQuality"
|
||||
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
color: 'white',
|
||||
fontSize: 13,
|
||||
@@ -33,7 +31,7 @@ export function AppHeader() {
|
||||
}}
|
||||
>
|
||||
InQ ROI
|
||||
</Link>
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
57
src/features/login/LoginPage.tsx
Normal file
57
src/features/login/LoginPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908C16.658 14.013 17.64 11.79 17.64 9.2z" fill="white"/>
|
||||
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="white"/>
|
||||
<path d="M3.964 10.707A5.41 5.41 0 0 1 3.682 9c0-.59.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.826.957 4.039l3.007-2.332z" fill="white"/>
|
||||
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 6.293C4.672 4.169 6.656 3.58 9 3.58z" fill="white"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { signIn, loading } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-6 px-8"
|
||||
style={{ animation: 'loginFadeIn 0.5s ease-out both' }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes loginFadeIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<img
|
||||
src="/inquality-logo-color.png"
|
||||
alt="InQuality"
|
||||
className="h-12 object-contain"
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">InQ ROI</h1>
|
||||
<p className="text-slate-500 mt-1 text-sm">Análisis de procesos y ROI de automatización</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={signIn}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
style={{ backgroundColor: '#F59845' }}
|
||||
>
|
||||
<GoogleIcon />
|
||||
Iniciar sesión con Google
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
Solo para usuarios con cuenta @inquality.com.py
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/lib/supabase.ts
Normal file
10
src/lib/supabase.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Variables de entorno VITE_SUPABASE_URL y VITE_SUPABASE_ANON_KEY son requeridas')
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
@@ -1,13 +1,13 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { createRouter, createRootRoute, createRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { WorkspacePage } from '@/features/workspace/WorkspacePage'
|
||||
import { ImportPage } from '@/features/import/ImportPage'
|
||||
import { LibraryPage } from '@/features/library/LibraryPage'
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||
import { LoginPage } from '@/features/login/LoginPage'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el
|
||||
// bundle inicial no lo incluya — se carga solo cuando el usuario llega al reporte.
|
||||
const ReportPage = lazy(() =>
|
||||
import('@/features/report/ReportPage').then((m) => ({ default: m.ReportPage }))
|
||||
)
|
||||
@@ -25,7 +25,40 @@ function ReportPageWithSuspense() {
|
||||
)
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
function RootComponent() {
|
||||
const { user, loading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (!user && pathname !== '/login') {
|
||||
void navigate({ to: '/login' })
|
||||
} else if (user && pathname === '/login') {
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
}, [user, loading, pathname, navigate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user && pathname !== '/login') return null
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({ component: RootComponent })
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -57,7 +90,15 @@ const settingsRoute = createRoute({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, importRoute, workspaceRoute, reportRoute, settingsRoute])
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
indexRoute,
|
||||
importRoute,
|
||||
workspaceRoute,
|
||||
reportRoute,
|
||||
settingsRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
Reference in New Issue
Block a user