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,
|
clubId.Value,
|
||||||
clubName,
|
clubName,
|
||||||
sportTypeEnum,
|
sportTypeEnum,
|
||||||
memberCount
|
memberCount,
|
||||||
|
Guid.Parse(tenantId)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user