feat: add admin auth hook and protected route guard
This commit is contained in:
42
src/components/ProtectedRoute.test.tsx
Normal file
42
src/components/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
const useAdminAuthMock = vi.fn()
|
||||||
|
vi.mock('../hooks/useAdminAuth', () => ({ default: () => useAdminAuthMock() }))
|
||||||
|
|
||||||
|
import ProtectedRoute from './ProtectedRoute'
|
||||||
|
|
||||||
|
function renderWithRoute() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/admin/products']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/login" element={<p>Login page</p>} />
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route path="/admin/products" element={<p>Products page</p>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProtectedRoute', () => {
|
||||||
|
it('renders nothing while loading', () => {
|
||||||
|
useAdminAuthMock.mockReturnValue({ session: null, loading: true })
|
||||||
|
renderWithRoute()
|
||||||
|
expect(screen.queryByText('Products page')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Login page')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects to /admin/login when there is no session', () => {
|
||||||
|
useAdminAuthMock.mockReturnValue({ session: null, loading: false })
|
||||||
|
renderWithRoute()
|
||||||
|
expect(screen.getByText('Login page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the protected content when there is a session', () => {
|
||||||
|
useAdminAuthMock.mockReturnValue({ session: { user: { id: 'admin' } }, loading: false })
|
||||||
|
renderWithRoute()
|
||||||
|
expect(screen.getByText('Products page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
12
src/components/ProtectedRoute.tsx
Normal file
12
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import useAdminAuth from '../hooks/useAdminAuth'
|
||||||
|
|
||||||
|
function ProtectedRoute() {
|
||||||
|
const { session, loading } = useAdminAuth()
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
if (!session) return <Navigate to="/admin/login" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute
|
||||||
59
src/hooks/useAdminAuth.test.ts
Normal file
59
src/hooks/useAdminAuth.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { Session } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const getSessionMock = vi.fn()
|
||||||
|
const onAuthStateChangeMock = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../lib/supabase', () => ({
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getSession: (...args: unknown[]) => getSessionMock(...args),
|
||||||
|
onAuthStateChange: (...args: unknown[]) => onAuthStateChangeMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const FAKE_SESSION = { access_token: 'token', user: { id: 'admin' } } as unknown as Session
|
||||||
|
|
||||||
|
describe('useAdminAuth', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getSessionMock.mockReset()
|
||||||
|
onAuthStateChangeMock.mockReset()
|
||||||
|
onAuthStateChangeMock.mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts loading, then resolves with the current session', async () => {
|
||||||
|
getSessionMock.mockResolvedValue({ data: { session: FAKE_SESSION } })
|
||||||
|
const { default: useAdminAuth } = await import('./useAdminAuth')
|
||||||
|
const { result } = renderHook(() => useAdminAuth())
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true)
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||||
|
expect(result.current.session).toBe(FAKE_SESSION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves with a null session when signed out', async () => {
|
||||||
|
getSessionMock.mockResolvedValue({ data: { session: null } })
|
||||||
|
const { default: useAdminAuth } = await import('./useAdminAuth')
|
||||||
|
const { result } = renderHook(() => useAdminAuth())
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||||
|
expect(result.current.session).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates session when the auth state changes', async () => {
|
||||||
|
getSessionMock.mockResolvedValue({ data: { session: null } })
|
||||||
|
let capturedCallback: ((event: string, session: Session | null) => void) | undefined
|
||||||
|
onAuthStateChangeMock.mockImplementation((callback) => {
|
||||||
|
capturedCallback = callback
|
||||||
|
return { data: { subscription: { unsubscribe: vi.fn() } } }
|
||||||
|
})
|
||||||
|
const { default: useAdminAuth } = await import('./useAdminAuth')
|
||||||
|
const { result } = renderHook(() => useAdminAuth())
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||||
|
capturedCallback?.('SIGNED_IN', FAKE_SESSION)
|
||||||
|
await waitFor(() => expect(result.current.session).toBe(FAKE_SESSION))
|
||||||
|
})
|
||||||
|
})
|
||||||
28
src/hooks/useAdminAuth.ts
Normal file
28
src/hooks/useAdminAuth.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Session } from '@supabase/supabase-js'
|
||||||
|
import { supabase } from '../lib/supabase'
|
||||||
|
|
||||||
|
export interface AdminAuthState {
|
||||||
|
session: Session | null
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useAdminAuth(): AdminAuthState {
|
||||||
|
const [session, setSession] = useState<Session | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
setSession(data.session)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: listener } = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
setSession(newSession)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => listener.subscription.unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { session, loading }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user