From bc9efdbbd9c9ca8eaeecde6a13ed58fc4de22328 Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Wed, 12 Aug 2026 10:56:35 +0200 Subject: [PATCH] feat: add admin auth hook and protected route guard --- src/components/ProtectedRoute.test.tsx | 42 ++++++++++++++++++ src/components/ProtectedRoute.tsx | 12 ++++++ src/hooks/useAdminAuth.test.ts | 59 ++++++++++++++++++++++++++ src/hooks/useAdminAuth.ts | 28 ++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/components/ProtectedRoute.test.tsx create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/hooks/useAdminAuth.test.ts create mode 100644 src/hooks/useAdminAuth.ts 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 } +}