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:
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