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,74 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useSession } from 'next-auth/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
type Club = {
id: string;
name: string;
sportType: string;
};
type TenantContextType = {
activeClubId: string | null;
setActiveClub: (clubId: string) => void;
userRole: string | null;
clubs: Club[];
};
const TenantContext = createContext<TenantContextType | undefined>(undefined);
export function TenantProvider({ children }: { children: ReactNode }) {
const { data: session, status } = useSession();
const [activeClubId, setActiveClubId] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: clubs = [] } = useQuery<Club[]>({
queryKey: ['my-clubs'],
queryFn: async () => {
const res = await fetch('/api/clubs/me');
if (!res.ok) return [];
return res.json();
},
enabled: status === 'authenticated',
});
useEffect(() => {
if (status === 'authenticated' && clubs.length > 0) {
const stored = localStorage.getItem('activeClubId');
if (stored && clubs.find(c => c.id === stored)) {
setActiveClubId(stored);
} else if (!activeClubId) {
setActiveClubId(clubs[0].id);
}
}
}, [status, clubs, activeClubId]);
useEffect(() => {
if (activeClubId) {
document.cookie = `X-Tenant-Id=${activeClubId}; path=/; max-age=86400`;
}
}, [activeClubId]);
const handleSetActiveClub = (clubId: string) => {
setActiveClubId(clubId);
localStorage.setItem('activeClubId', clubId);
document.cookie = `X-Tenant-Id=${clubId}; path=/; max-age=86400`;
queryClient.invalidateQueries();
};
const userRole = activeClubId && session?.user?.clubs ? session.user.clubs[activeClubId] || null : null;
return (
<TenantContext.Provider value={{ activeClubId, setActiveClub: handleSetActiveClub, userRole, clubs }}>
{children}
</TenantContext.Provider>
);
}
export function useTenant() {
const context = useContext(TenantContext);
if (!context) throw new Error('useTenant must be used within TenantProvider');
return context;
}