diff --git a/.sisyphus/notepads/club-work-manager/learnings.md b/.sisyphus/notepads/club-work-manager/learnings.md index 67dcad7..de37b1e 100644 --- a/.sisyphus/notepads/club-work-manager/learnings.md +++ b/.sisyphus/notepads/club-work-manager/learnings.md @@ -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. diff --git a/.sisyphus/plans/club-work-manager.md b/.sisyphus/plans/club-work-manager.md index d6f71c3..1c0cd0a 100644 --- a/.sisyphus/plans/club-work-manager.md +++ b/.sisyphus/plans/club-work-manager.md @@ -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`): diff --git a/frontend/bun.lock b/frontend/bun.lock index e0dcdb9..6f33ae6 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -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=="], diff --git a/frontend/package.json b/frontend/package.json index 580b7c9..1df3d6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/(protected)/layout.tsx b/frontend/src/app/(protected)/layout.tsx new file mode 100644 index 0000000..10c01b4 --- /dev/null +++ b/frontend/src/app/(protected)/layout.tsx @@ -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 ( + +
+ + +
+
+
+ +
+
+ +
+
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87e..a78c1b4 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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({ - {children} + + + + {children} + + + ); diff --git a/frontend/src/components/__tests__/auth-guard.test.tsx b/frontend/src/components/__tests__/auth-guard.test.tsx new file mode 100644 index 0000000..5e64318 --- /dev/null +++ b/frontend/src/components/__tests__/auth-guard.test.tsx @@ -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(
Protected
); + 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(
Protected
); + 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(
Protected
); + 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(
Protected
); + 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(
Protected
); + 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(
Protected Content
); + expect(screen.getByText('Protected Content')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/club-switcher.test.tsx b/frontend/src/components/__tests__/club-switcher.test.tsx new file mode 100644 index 0000000..a1a85bf --- /dev/null +++ b/frontend/src/components/__tests__/club-switcher.test.tsx @@ -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 }) =>
{children}
, + DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode, asChild?: boolean }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
---
, +})); + +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(); + 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(); + 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(); + + const swimClub = screen.getByText('Swim Club'); + fireEvent.click(swimClub); + + expect(mockSetActiveClub).toHaveBeenCalledWith('club-2'); + }); +}); diff --git a/frontend/src/components/auth-guard.tsx b/frontend/src/components/auth-guard.tsx new file mode 100644 index 0000000..c18c82e --- /dev/null +++ b/frontend/src/components/auth-guard.tsx @@ -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 ( +
+

Loading...

+
+ ); + } + + if (status === 'unauthenticated') { + return null; + } + + if (clubs.length === 0 && status === 'authenticated') { + return ( +
+

No Clubs Found

+

Contact admin to get access to a club

+
+ ); + } + + if (clubs.length > 1 && !activeClubId) { + return null; + } + + return <>{children}; +} diff --git a/frontend/src/components/club-switcher.tsx b/frontend/src/components/club-switcher.tsx new file mode 100644 index 0000000..675804e --- /dev/null +++ b/frontend/src/components/club-switcher.tsx @@ -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 ( + + + + + + My Clubs + + {clubs.map(club => ( + setActiveClub(club.id)} + className="flex items-center justify-between cursor-pointer" + > + {club.name} + + {club.sportType} + + + ))} + + + ); +} diff --git a/frontend/src/components/sign-out-button.tsx b/frontend/src/components/sign-out-button.tsx new file mode 100644 index 0000000..ebb7bab --- /dev/null +++ b/frontend/src/components/sign-out-button.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/contexts/tenant-context.tsx b/frontend/src/contexts/tenant-context.tsx new file mode 100644 index 0000000..0a67012 --- /dev/null +++ b/frontend/src/contexts/tenant-context.tsx @@ -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(undefined); + +export function TenantProvider({ children }: { children: ReactNode }) { + const { data: session, status } = useSession(); + const [activeClubId, setActiveClubId] = useState(null); + const queryClient = useQueryClient(); + + const { data: clubs = [] } = useQuery({ + 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 ( + + {children} + + ); +} + +export function useTenant() { + const context = useContext(TenantContext); + if (!context) throw new Error('useTenant must be used within TenantProvider'); + return context; +} diff --git a/frontend/src/providers/query-provider.tsx b/frontend/src/providers/query-provider.tsx new file mode 100644 index 0000000..a3e620b --- /dev/null +++ b/frontend/src/providers/query-provider.tsx @@ -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 ( + + {children} + + ); +}