feat: add admin auth hook and protected route guard

This commit is contained in:
belviskhoremk
2026-08-12 10:56:35 +02:00
parent bcc606db28
commit bc9efdbbd9
4 changed files with 141 additions and 0 deletions

View 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()
})
})

View 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

View 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
View 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 }
}