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:
WorkClub Automation
2026-03-03 19:01:13 +01:00
parent 4322ec925d
commit d3f8e329c3
11 changed files with 1050 additions and 1656 deletions

View 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',
});
});
});

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