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.
75 lines
2.2 KiB
TypeScript
75 lines
2.2 KiB
TypeScript
'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;
|
|
}
|