diff --git a/src/components/ProtectedRoute.test.tsx b/src/components/ProtectedRoute.test.tsx
new file mode 100644
index 0000000..c0a1929
--- /dev/null
+++ b/src/components/ProtectedRoute.test.tsx
@@ -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(
+
+
+ Login page
} />
+ }>
+ Products page} />
+
+
+ ,
+ )
+}
+
+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()
+ })
+})
diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..aef24ab
--- /dev/null
+++ b/src/components/ProtectedRoute.tsx
@@ -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
+ return
+}
+
+export default ProtectedRoute
diff --git a/src/hooks/useAdminAuth.test.ts b/src/hooks/useAdminAuth.test.ts
new file mode 100644
index 0000000..bbc5447
--- /dev/null
+++ b/src/hooks/useAdminAuth.test.ts
@@ -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))
+ })
+})
diff --git a/src/hooks/useAdminAuth.ts b/src/hooks/useAdminAuth.ts
new file mode 100644
index 0000000..e106610
--- /dev/null
+++ b/src/hooks/useAdminAuth.ts
@@ -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(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 }
+}