fix: stabilize auth-to-tenant flow and correct tenant header mapping

Resolve post-login routing and tenant context issues by proxying frontend API
calls, redirecting authenticated users away from /login, and hardening club
loading with retries/loading guards.

Align tenant identity end-to-end by returning tenantId in /api/clubs/me and
sending X-Tenant-Id from cookie-backed tenantId instead of local clubId,
restoring authorized tasks/shifts data access after club selection.
This commit is contained in:
WorkClub Automation
2026-03-06 08:01:09 +01:00
parent dbc8964f07
commit 9950185213
7 changed files with 82 additions and 27 deletions

View File

@@ -112,7 +112,8 @@ public class ClubService
clubId.Value, clubId.Value,
clubName, clubName,
sportTypeEnum, sportTypeEnum,
memberCount memberCount,
Guid.Parse(tenantId)
)); ));
} }
} }

View File

@@ -4,4 +4,5 @@ public record ClubListDto(
Guid Id, Guid Id,
string Name, string Name,
string SportType, string SportType,
int MemberCount); int MemberCount,
Guid TenantId);

View File

@@ -2,6 +2,15 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
async rewrites() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5001';
return [
{
source: '/api/:path((?!auth).*)',
destination: `${apiUrl}/api/:path*`,
},
];
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,12 +1,24 @@
'use client'; 'use client';
import { signIn } from 'next-auth/react'; import { useEffect } from 'react';
import { signIn, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export default function LoginPage() { export default function LoginPage() {
const { status } = useSession();
const router = useRouter();
// Redirect to dashboard if already authenticated
useEffect(() => {
if (status === 'authenticated') {
router.push('/dashboard');
}
}, [status, router]);
const handleSignIn = () => { const handleSignIn = () => {
signIn('keycloak'); signIn('keycloak', { callbackUrl: '/dashboard' });
}; };
return ( return (

View File

@@ -7,7 +7,7 @@ import { useTenant } from '../contexts/tenant-context';
export function AuthGuard({ children }: { children: ReactNode }) { export function AuthGuard({ children }: { children: ReactNode }) {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const { activeClubId, clubs, setActiveClub } = useTenant(); const { activeClubId, clubs, setActiveClub, clubsLoading } = useTenant();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
@@ -38,6 +38,14 @@ export function AuthGuard({ children }: { children: ReactNode }) {
return null; return null;
} }
if (status === 'authenticated' && clubsLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<p>Loading...</p>
</div>
);
}
if (clubs.length === 0 && status === 'authenticated') { if (clubs.length === 0 && status === 'authenticated') {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4"> <div className="flex flex-col items-center justify-center min-h-screen gap-4">

View File

@@ -8,6 +8,7 @@ type Club = {
id: string; id: string;
name: string; name: string;
sportType: string; sportType: string;
tenantId: string;
}; };
type TenantContextType = { type TenantContextType = {
@@ -15,6 +16,8 @@ type TenantContextType = {
setActiveClub: (clubId: string) => void; setActiveClub: (clubId: string) => void;
userRole: string | null; userRole: string | null;
clubs: Club[]; clubs: Club[];
clubsLoading: boolean;
clubsError: Error | null;
}; };
const TenantContext = createContext<TenantContextType | undefined>(undefined); const TenantContext = createContext<TenantContextType | undefined>(undefined);
@@ -24,14 +27,20 @@ export function TenantProvider({ children }: { children: ReactNode }) {
const [activeClubId, setActiveClubId] = useState<string | null>(null); const [activeClubId, setActiveClubId] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: clubs = [] } = useQuery<Club[]>({ const { data: clubs = [], isLoading: clubsLoading, error: clubsError } = useQuery<Club[]>({
queryKey: ['my-clubs'], queryKey: ['my-clubs', session?.accessToken],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/api/clubs/me'); const res = await fetch('/api/clubs/me', {
if (!res.ok) return []; headers: {
Authorization: `Bearer ${session?.accessToken}`,
},
});
if (!res.ok) throw new Error(`Failed to fetch clubs: ${res.statusText}`);
return res.json(); return res.json();
}, },
enabled: status === 'authenticated', enabled: status === 'authenticated' && !!session?.accessToken,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 10000),
}); });
useEffect(() => { useEffect(() => {
@@ -47,21 +56,27 @@ export function TenantProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (activeClubId) { if (activeClubId) {
document.cookie = `X-Tenant-Id=${activeClubId}; path=/; max-age=86400`; const selectedClub = clubs.find(c => c.id === activeClubId);
if (selectedClub) {
document.cookie = `X-Tenant-Id=${selectedClub.tenantId}; path=/; max-age=86400`;
}
} }
}, [activeClubId]); }, [activeClubId, clubs]);
const handleSetActiveClub = (clubId: string) => { const handleSetActiveClub = (clubId: string) => {
setActiveClubId(clubId); setActiveClubId(clubId);
localStorage.setItem('activeClubId', clubId); localStorage.setItem('activeClubId', clubId);
document.cookie = `X-Tenant-Id=${clubId}; path=/; max-age=86400`; const selectedClub = clubs.find(c => c.id === clubId);
if (selectedClub) {
document.cookie = `X-Tenant-Id=${selectedClub.tenantId}; path=/; max-age=86400`;
}
queryClient.invalidateQueries(); queryClient.invalidateQueries();
}; };
const userRole = activeClubId && session?.user?.clubs ? session.user.clubs[activeClubId] || null : null; const userRole = activeClubId && session?.user?.clubs ? session.user.clubs[activeClubId] || null : null;
return ( return (
<TenantContext.Provider value={{ activeClubId, setActiveClub: handleSetActiveClub, userRole, clubs }}> <TenantContext.Provider value={{ activeClubId, setActiveClub: handleSetActiveClub, userRole, clubs, clubsLoading, clubsError: clubsError || null }}>
{children} {children}
</TenantContext.Provider> </TenantContext.Provider>
); );

View File

@@ -1,22 +1,31 @@
import { getSession } from 'next-auth/react'; import { getSession } from 'next-auth/react';
const ACTIVE_CLUB_KEY = 'activeClubId'; function getTenantIdFromCookie(): string | null {
if (typeof document === 'undefined') {
return null;
}
try {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'X-Tenant-Id') {
return decodeURIComponent(value);
}
}
} catch {
// Cookie parsing may fail in some environments
}
return null;
}
export async function apiClient( export async function apiClient(
url: string, url: string,
options: RequestInit = {} options: RequestInit = {}
): Promise<Response> { ): Promise<Response> {
const session = await getSession(); const session = await getSession();
let activeClubId: string | null = null; const tenantId = getTenantIdFromCookie();
try {
activeClubId = typeof localStorage !== 'undefined'
? localStorage.getItem(ACTIVE_CLUB_KEY)
: null;
} catch {
// localStorage may not be available in some environments
activeClubId = null;
}
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -27,8 +36,8 @@ export async function apiClient(
headers['Authorization'] = `Bearer ${session.accessToken}`; headers['Authorization'] = `Bearer ${session.accessToken}`;
} }
if (activeClubId) { if (tenantId) {
headers['X-Tenant-Id'] = activeClubId; headers['X-Tenant-Id'] = tenantId;
} }
return fetch(url, { return fetch(url, {