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:
@@ -1546,3 +1546,5 @@ frontend/
|
||||
3. **Add auth test**: Navigate to login flow (tests NextAuth integration)
|
||||
4. **Add form test**: Fill tasks form and submit (tests API integration)
|
||||
|
||||
- **Testing Radix UI DropdownMenu**: When testing Radix UI components like `DropdownMenu` with React Testing Library, you often need to either use complex test setups waiting for portal rendering and pointer events, or simply mock the Radix UI components out to test just the integration logic. Mocking `DropdownMenu`, `DropdownMenuTrigger`, etc., makes checking dropdown logic faster and less prone to portal-related DOM test issues.
|
||||
- **Provider Architecture in Next.js App Router**: Combining multiple providers like `SessionProvider`, `QueryProvider`, and a custom context provider like `TenantProvider` in `app/layout.tsx` is an effective way to handle global state. Custom components needing hooks must have `"use client"` at the top.
|
||||
|
||||
@@ -1680,7 +1680,7 @@ Max Concurrent: 6 (Wave 1)
|
||||
|
||||
---
|
||||
|
||||
- [ ] 18. App Layout + Club-Switcher + Auth Guard
|
||||
- [x] 18. App Layout + Club-Switcher + Auth Guard
|
||||
|
||||
**What to do**:
|
||||
- Create root layout (`frontend/src/app/layout.tsx`):
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.3",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
@@ -543,6 +544,10 @@
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.3",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
|
||||
53
frontend/src/app/(protected)/layout.tsx
Normal file
53
frontend/src/app/(protected)/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AuthGuard } from '@/components/auth-guard';
|
||||
import { ClubSwitcher } from '@/components/club-switcher';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { SignOutButton } from '@/components/sign-out-button';
|
||||
|
||||
export default function ProtectedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<aside className="hidden md:flex flex-col w-64 bg-white border-r">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-xl font-bold">WorkClub</h1>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
<Link href="/dashboard" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/tasks" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
|
||||
Tasks
|
||||
</Link>
|
||||
<Link href="/shifts" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
|
||||
Shifts
|
||||
</Link>
|
||||
<Link href="/members" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
|
||||
Members
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="bg-white border-b h-16 flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClubSwitcher />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { QueryProvider } from "@/providers/query-provider";
|
||||
import { TenantProvider } from "@/contexts/tenant-context";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +16,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "WorkClub Manager",
|
||||
description: "Club management application",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,7 +30,13 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<SessionProvider>
|
||||
<QueryProvider>
|
||||
<TenantProvider>
|
||||
{children}
|
||||
</TenantProvider>
|
||||
</QueryProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
91
frontend/src/components/__tests__/auth-guard.test.tsx
Normal file
91
frontend/src/components/__tests__/auth-guard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
70
frontend/src/components/__tests__/club-switcher.test.tsx
Normal file
70
frontend/src/components/__tests__/club-switcher.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
55
frontend/src/components/auth-guard.tsx
Normal file
55
frontend/src/components/auth-guard.tsx
Normal 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}</>;
|
||||
}
|
||||
54
frontend/src/components/club-switcher.tsx
Normal file
54
frontend/src/components/club-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/sign-out-button.tsx
Normal file
14
frontend/src/components/sign-out-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/contexts/tenant-context.tsx
Normal file
74
frontend/src/contexts/tenant-context.tsx
Normal 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;
|
||||
}
|
||||
21
frontend/src/providers/query-provider.tsx
Normal file
21
frontend/src/providers/query-provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactNode, useState } from 'react';
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user