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:
@@ -112,7 +112,8 @@ public class ClubService
|
||||
clubId.Value,
|
||||
clubName,
|
||||
sportTypeEnum,
|
||||
memberCount
|
||||
memberCount,
|
||||
Guid.Parse(tenantId)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public record ClubListDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string SportType,
|
||||
int MemberCount);
|
||||
int MemberCount,
|
||||
Guid TenantId);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user