feat(ui): add layout, club-switcher, and auth guard

Implements Task 18: App Layout + Club-Switcher + Auth Guard

New components:
- TenantContext: Manages activeClubId state with TanStack Query
- QueryProvider: TanStack Query client wrapper (60s stale time)
- AuthGuard: Auth + tenant redirect logic (unauthenticated → /login)
- ClubSwitcher: shadcn DropdownMenu for switching clubs
- SignOutButton: Simple sign out button
- Protected layout: Sidebar navigation + top bar with ClubSwitcher

Key features:
- Fetches clubs from /api/clubs/me
- Auto-loads activeClubId from localStorage
- Sets X-Tenant-Id cookie on club switch
- Invalidates all queries on club switch
- Redirect logic: unauthenticated → /login, 0 clubs → message, 1 club → auto-select, >1 clubs + no active → /select-club

TDD:
- 6 AuthGuard tests (loading, unauthenticated, 0 clubs, 1 club, multiple clubs, authenticated)
- 3 ClubSwitcher tests (renders current club, lists all clubs, calls setActiveClub on selection)

Dependencies:
- Added @tanstack/react-query

All tests pass (25/25). Build succeeds.
This commit is contained in:
WorkClub Automation
2026-03-03 19:59:14 +01:00
parent 54b893e34e
commit 46bbac355b
13 changed files with 453 additions and 4 deletions

View File

@@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthGuard } from '../auth-guard';
import { useSession } from 'next-auth/react';
import { useTenant } from '../../contexts/tenant-context';
import { useRouter } from 'next/navigation';
vi.mock('next-auth/react', () => ({
useSession: vi.fn(),
}));
vi.mock('../../contexts/tenant-context', () => ({
useTenant: vi.fn(),
}));
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
}));
describe('AuthGuard', () => {
const mockPush = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
});
it('renders loading state when session is loading', () => {
vi.mocked(useSession).mockReturnValue({ data: null, status: 'loading' } as any);
vi.mocked(useTenant).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('redirects to /login when unauthenticated', () => {
vi.mocked(useSession).mockReturnValue({ data: null, status: 'unauthenticated' } as any);
vi.mocked(useTenant).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockPush).toHaveBeenCalledWith('/login');
});
it('shows Contact admin when 0 clubs', () => {
vi.mocked(useSession).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
vi.mocked(useTenant).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(screen.getByText('Contact admin to get access to a club')).toBeInTheDocument();
});
it('auto-selects when 1 club and no active club', () => {
const mockSetActiveClub = vi.fn();
vi.mocked(useSession).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
vi.mocked(useTenant).mockReturnValue({
activeClubId: null,
clubs: [{ id: 'club-1', name: 'Club 1' }],
setActiveClub: mockSetActiveClub,
userRole: null
} as any);
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockSetActiveClub).toHaveBeenCalledWith('club-1');
});
it('redirects to /select-club when multiple clubs and no active club', () => {
vi.mocked(useSession).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
vi.mocked(useTenant).mockReturnValue({
activeClubId: null,
clubs: [{ id: 'club-1', name: 'Club 1' }, { id: 'club-2', name: 'Club 2' }],
setActiveClub: vi.fn(),
userRole: null
} as any);
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockPush).toHaveBeenCalledWith('/select-club');
});
it('renders children when authenticated and active club is set', () => {
vi.mocked(useSession).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
vi.mocked(useTenant).mockReturnValue({
activeClubId: 'club-1',
clubs: [{ id: 'club-1', name: 'Club 1' }],
setActiveClub: vi.fn(),
userRole: 'admin'
} as any);
render(<AuthGuard><div>Protected Content</div></AuthGuard>);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,70 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ClubSwitcher } from '../club-switcher';
import { useTenant } from '../../contexts/tenant-context';
vi.mock('../../contexts/tenant-context', () => ({
useTenant: vi.fn(),
}));
vi.mock('../ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode, asChild?: boolean }) => <div data-testid="trigger">{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="content">{children}</div>,
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => <div onClick={onClick} data-testid="menu-item">{children}</div>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <div>---</div>,
}));
describe('ClubSwitcher', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state when clubs is empty', () => {
vi.mocked(useTenant).mockReturnValue({
activeClubId: null,
clubs: [],
setActiveClub: vi.fn(),
userRole: null
} as any);
render(<ClubSwitcher />);
expect(screen.getByRole('button')).toHaveTextContent('Select Club');
});
it('renders current club name and sport type badge', () => {
vi.mocked(useTenant).mockReturnValue({
activeClubId: 'club-1',
clubs: [
{ id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' },
{ id: 'club-2', name: 'Swim Club', sportType: 'Swimming' },
],
setActiveClub: vi.fn(),
userRole: 'admin'
} as any);
render(<ClubSwitcher />);
expect(screen.getAllByText('Tennis Club')[0]).toBeInTheDocument();
});
it('calls setActiveClub when club is selected', () => {
const mockSetActiveClub = vi.fn();
vi.mocked(useTenant).mockReturnValue({
activeClubId: 'club-1',
clubs: [
{ id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' },
{ id: 'club-2', name: 'Swim Club', sportType: 'Swimming' },
],
setActiveClub: mockSetActiveClub,
userRole: 'admin'
} as any);
render(<ClubSwitcher />);
const swimClub = screen.getByText('Swim Club');
fireEvent.click(swimClub);
expect(mockSetActiveClub).toHaveBeenCalledWith('club-2');
});
});

View File

@@ -0,0 +1,55 @@
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { ReactNode, useEffect } from 'react';
import { useTenant } from '../contexts/tenant-context';
export function AuthGuard({ children }: { children: ReactNode }) {
const { data: session, status } = useSession();
const { activeClubId, clubs, setActiveClub } = useTenant();
const router = useRouter();
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login');
}
}, [status, router]);
useEffect(() => {
if (status === 'authenticated' && clubs.length > 0) {
if (clubs.length === 1 && !activeClubId) {
setActiveClub(clubs[0].id);
} else if (clubs.length > 1 && !activeClubId) {
router.push('/select-club');
}
}
}, [status, clubs, activeClubId, router, setActiveClub]);
if (status === 'loading') {
return (
<div className="flex items-center justify-center min-h-screen">
<p>Loading...</p>
</div>
);
}
if (status === 'unauthenticated') {
return null;
}
if (clubs.length === 0 && status === 'authenticated') {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
<h2 className="text-2xl font-bold">No Clubs Found</h2>
<p>Contact admin to get access to a club</p>
</div>
);
}
if (clubs.length > 1 && !activeClubId) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useTenant } from '../contexts/tenant-context';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
export function ClubSwitcher() {
const { activeClubId, clubs, setActiveClub } = useTenant();
const activeClub = clubs.find(c => c.id === activeClubId);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
{activeClub ? (
<>
<span>{activeClub.name}</span>
<Badge variant="secondary" className="ml-2">
{activeClub.sportType}
</Badge>
</>
) : (
'Select Club'
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuLabel>My Clubs</DropdownMenuLabel>
<DropdownMenuSeparator />
{clubs.map(club => (
<DropdownMenuItem
key={club.id}
onClick={() => setActiveClub(club.id)}
className="flex items-center justify-between cursor-pointer"
>
<span>{club.name}</span>
<Badge variant="outline" className="ml-2 text-xs">
{club.sportType}
</Badge>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { signOut } from 'next-auth/react';
import { Button } from './ui/button';
import { LogOut } from 'lucide-react';
export function SignOutButton() {
return (
<Button variant="ghost" size="sm" onClick={() => signOut()}>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</Button>
);
}