feat(frontend-auth): complete NextAuth.js Keycloak integration with middleware, hooks, and API utility
- Add middleware.ts for route protection (redirects unauthenticated users to /login) - Add useActiveClub() hook for managing active club context (localStorage + session) - Add apiClient() fetch wrapper with automatic Authorization + X-Tenant-Id headers - Configure vitest with jsdom environment and global test setup - Add comprehensive test coverage: 16/16 tests passing (hooks + API utility) - Install test dependencies: vitest, @testing-library/react, @vitejs/plugin-react, happy-dom Task 10 COMPLETE - all acceptance criteria met
This commit is contained in:
127
frontend/src/hooks/__tests__/useActiveClub.test.ts
Normal file
127
frontend/src/hooks/__tests__/useActiveClub.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useActiveClub } from '../useActiveClub';
|
||||
import type { Session } from 'next-auth';
|
||||
|
||||
const mockUseSession = vi.fn();
|
||||
|
||||
vi.mock('next-auth/react', () => ({
|
||||
useSession: () => mockUseSession(),
|
||||
}));
|
||||
|
||||
describe('useActiveClub', () => {
|
||||
let localStorageData: Record<string, string> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageData = {};
|
||||
|
||||
mockUseSession.mockReturnValue({
|
||||
data: {
|
||||
user: {
|
||||
id: '1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
clubs: {
|
||||
'club-1': 'owner',
|
||||
'club-2': 'member',
|
||||
'club-3': 'admin',
|
||||
},
|
||||
},
|
||||
accessToken: 'mock-token',
|
||||
expires: '2099-01-01',
|
||||
},
|
||||
status: 'authenticated',
|
||||
});
|
||||
|
||||
vi.mocked(localStorage.getItem).mockImplementation((key: string) => {
|
||||
return localStorageData[key] || null;
|
||||
});
|
||||
|
||||
vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
|
||||
localStorageData[key] = value;
|
||||
});
|
||||
|
||||
vi.mocked(localStorage.clear).mockImplementation(() => {
|
||||
localStorageData = {};
|
||||
});
|
||||
});
|
||||
|
||||
it('should return first club from session when localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useActiveClub());
|
||||
|
||||
expect(result.current.activeClubId).toBe('club-1');
|
||||
expect(result.current.role).toBe('owner');
|
||||
});
|
||||
|
||||
it('should return active club from localStorage if valid', () => {
|
||||
localStorageData['activeClubId'] = 'club-2';
|
||||
|
||||
const { result } = renderHook(() => useActiveClub());
|
||||
|
||||
expect(result.current.activeClubId).toBe('club-2');
|
||||
expect(result.current.role).toBe('member');
|
||||
});
|
||||
|
||||
it('should fallback to first club if localStorage contains invalid club ID', () => {
|
||||
localStorageData['activeClubId'] = 'invalid-club';
|
||||
|
||||
const { result } = renderHook(() => useActiveClub());
|
||||
|
||||
expect(result.current.activeClubId).toBe('club-1');
|
||||
expect(result.current.role).toBe('owner');
|
||||
});
|
||||
|
||||
it('should update localStorage when setActiveClub is called', () => {
|
||||
const { result } = renderHook(() => useActiveClub());
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveClub('club-3');
|
||||
});
|
||||
|
||||
expect(result.current.activeClubId).toBe('club-3');
|
||||
expect(result.current.role).toBe('admin');
|
||||
expect(localStorageData['activeClubId']).toBe('club-3');
|
||||
});
|
||||
|
||||
it('should return null when no session exists', () => {
|
||||
mockUseSession.mockReturnValueOnce({
|
||||
data: null,
|
||||
status: 'unauthenticated',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useActiveClub());
|
||||
|
||||
expect(result.current.activeClubId).toBeNull();
|
||||
expect(result.current.role).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when user has no clubs', () => {
|
||||
mockUseSession.mockReturnValueOnce({
|
||||
data: {
|
||||
user: {
|
||||
id: '1',
|
||||
name: 'Test User',
|
||||
clubs: {},
|
||||
},
|
||||
accessToken: 'mock-token',
|
||||
expires: '2099-01-01',
|
||||
},
|
||||
status: 'authenticated',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useActiveClub());
|
||||
|
||||
expect(result.current.activeClubId).toBeNull();
|
||||
expect(result.current.role).toBeNull();
|
||||
});
|
||||
|
||||
it('should return all clubs from session', () => {
|
||||
const { result } = renderHook(() => useActiveClub());
|
||||
|
||||
expect(result.current.clubs).toEqual({
|
||||
'club-1': 'owner',
|
||||
'club-2': 'member',
|
||||
'club-3': 'admin',
|
||||
});
|
||||
});
|
||||
});
|
||||
51
frontend/src/hooks/useActiveClub.ts
Normal file
51
frontend/src/hooks/useActiveClub.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const ACTIVE_CLUB_KEY = 'activeClubId';
|
||||
|
||||
export interface ActiveClubData {
|
||||
activeClubId: string | null;
|
||||
role: string | null;
|
||||
clubs: Record<string, string> | null;
|
||||
setActiveClub: (clubId: string) => void;
|
||||
}
|
||||
|
||||
export function useActiveClub(): ActiveClubData {
|
||||
const { data: session, status } = useSession();
|
||||
const [activeClubId, setActiveClubIdState] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated' && session?.user?.clubs) {
|
||||
const clubs = session.user.clubs;
|
||||
const storedClubId = localStorage.getItem(ACTIVE_CLUB_KEY);
|
||||
|
||||
if (storedClubId && clubs[storedClubId]) {
|
||||
setActiveClubIdState(storedClubId);
|
||||
} else {
|
||||
const firstClubId = Object.keys(clubs)[0];
|
||||
if (firstClubId) {
|
||||
setActiveClubIdState(firstClubId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [session, status]);
|
||||
|
||||
const setActiveClub = (clubId: string) => {
|
||||
if (session?.user?.clubs && session.user.clubs[clubId]) {
|
||||
localStorage.setItem(ACTIVE_CLUB_KEY, clubId);
|
||||
setActiveClubIdState(clubId);
|
||||
}
|
||||
};
|
||||
|
||||
const clubs = session?.user?.clubs || null;
|
||||
const role = activeClubId && clubs ? clubs[activeClubId] : null;
|
||||
|
||||
return {
|
||||
activeClubId,
|
||||
role,
|
||||
clubs,
|
||||
setActiveClub,
|
||||
};
|
||||
}
|
||||
154
frontend/src/lib/__tests__/api.test.ts
Normal file
154
frontend/src/lib/__tests__/api.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { apiClient } from '../api';
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const mockGetSession = vi.fn();
|
||||
|
||||
vi.mock('next-auth/react', () => ({
|
||||
getSession: () => mockGetSession(),
|
||||
}));
|
||||
|
||||
describe('apiClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: {
|
||||
id: '1',
|
||||
clubs: {
|
||||
'club-1': 'owner',
|
||||
},
|
||||
},
|
||||
accessToken: 'mock-access-token',
|
||||
expires: '2099-01-01',
|
||||
});
|
||||
|
||||
(global.localStorage.getItem as any).mockReturnValue('club-1');
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should add Authorization header with access token', async () => {
|
||||
await apiClient('/api/test');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should add X-Tenant-Id header with active club ID', async () => {
|
||||
await apiClient('/api/test');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-Tenant-Id': 'club-1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should add Content-Type header by default', async () => {
|
||||
await apiClient('/api/test');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge custom headers with default headers', async () => {
|
||||
await apiClient('/api/test', {
|
||||
headers: {
|
||||
'Custom-Header': 'custom-value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
'X-Tenant-Id': 'club-1',
|
||||
'Content-Type': 'application/json',
|
||||
'Custom-Header': 'custom-value',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow overriding default headers', async () => {
|
||||
await apiClient('/api/test', {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'text/plain',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass through other fetch options', async () => {
|
||||
await apiClient('/api/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'value' }),
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'value' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return Response object directly', async () => {
|
||||
const response = await apiClient('/api/test');
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should not add Authorization header when session has no token', async () => {
|
||||
mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: '1', clubs: { 'club-1': 'owner' } },
|
||||
accessToken: undefined,
|
||||
expires: '2099-01-01',
|
||||
});
|
||||
|
||||
await apiClient('/api/test');
|
||||
|
||||
const callHeaders = (global.fetch as any).mock.calls[0][1].headers;
|
||||
expect(callHeaders.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not add X-Tenant-Id header when no active club', async () => {
|
||||
(global.localStorage.getItem as any).mockReturnValueOnce(null);
|
||||
|
||||
await apiClient('/api/test');
|
||||
|
||||
const callHeaders = (global.fetch as any).mock.calls[0][1].headers;
|
||||
expect(callHeaders['X-Tenant-Id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
31
frontend/src/lib/api.ts
Normal file
31
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getSession } from 'next-auth/react';
|
||||
|
||||
const ACTIVE_CLUB_KEY = 'activeClubId';
|
||||
|
||||
export async function apiClient(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const session = await getSession();
|
||||
const activeClubId = typeof window !== 'undefined'
|
||||
? localStorage.getItem(ACTIVE_CLUB_KEY)
|
||||
: null;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (session?.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
|
||||
if (activeClubId) {
|
||||
headers['X-Tenant-Id'] = activeClubId;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
34
frontend/src/middleware.ts
Normal file
34
frontend/src/middleware.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { auth } from '@/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const publicRoutes = ['/', '/login'];
|
||||
const authRoutes = ['/api/auth'];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (publicRoutes.includes(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (authRoutes.some(route => pathname.startsWith(route))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
loginUrl.searchParams.set('callbackUrl', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\..*|api/auth).*)',
|
||||
],
|
||||
};
|
||||
12
frontend/src/test/setup.ts
Normal file
12
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
global.localStorage = localStorageMock as any;
|
||||
|
||||
Reference in New Issue
Block a user