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,
clubName,
sportTypeEnum,
memberCount
memberCount,
Guid.Parse(tenantId)
));
}
}

View File

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

View File

@@ -2,6 +2,15 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
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;

View File

@@ -1,12 +1,24 @@
'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 { Button } from '@/components/ui/button';
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 = () => {
signIn('keycloak');
signIn('keycloak', { callbackUrl: '/dashboard' });
};
return (

View File

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

View File

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

View File

@@ -1,22 +1,31 @@
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(
url: string,
options: RequestInit = {}
): Promise<Response> {
const session = await getSession();
let activeClubId: string | null = null;
try {
activeClubId = typeof localStorage !== 'undefined'
? localStorage.getItem(ACTIVE_CLUB_KEY)
: null;
} catch {
// localStorage may not be available in some environments
activeClubId = null;
}
const tenantId = getTenantIdFromCookie();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -27,8 +36,8 @@ export async function apiClient(
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
if (activeClubId) {
headers['X-Tenant-Id'] = activeClubId;
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
return fetch(url, {