diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index b490e9d..92492c7 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -11,6 +11,7 @@ import { HttpStatus, Get, Patch, + UnauthorizedException, } from '@nestjs/common'; import type { Response } from 'express'; import { @@ -173,6 +174,33 @@ export class AuthController { ); } + // ========================= + // REFRESH + // ========================= + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Refresh access token using refresh cookie' }) + @ApiResponse({ status: 200, description: 'Access token refreshed' }) + @ApiUnauthorizedResponse({ description: 'Invalid or missing refresh token' }) + async refresh(@Req() req, @Res({ passthrough: true }) res: Response) { + const refreshToken = req?.cookies?.refreshToken; + + if (!refreshToken) { + throw new UnauthorizedException('Refresh token not found'); + } + + const result = await this.authService.refreshToken(refreshToken); + + this.setAccessToken(res, result.data.accessToken); + + return { + success: true, + data: { + accessToken: result.data.accessToken, + }, + }; + } + // ========================= // LOGOUT // ========================= diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 409d9b8..47d3639 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -26,6 +26,18 @@ function isPublicInvitationRequest(url: string | undefined): boolean { ); } +/** Session bootstrap / auth endpoints where 401 means "not logged in", not "retry refresh". */ +function shouldSkipRefreshRetry(url: string | undefined): boolean { + if (!url) return false; + return ( + url.includes('/auth/profile') || + url.includes('/auth/refresh') || + url.includes('/auth/login') || + url.includes('/auth/register') || + url.includes('/auth/logout') + ); +} + // ❌ REMOVE request interceptor completely (no Authorization header) // ✅ Response interceptor @@ -37,7 +49,8 @@ apiClient.interceptors.response.use( if ( error.response?.status === 401 && !originalRequest._retry && - !isPublicInvitationRequest(originalRequest.url) + !isPublicInvitationRequest(originalRequest.url) && + !shouldSkipRefreshRetry(originalRequest.url) ) { originalRequest._retry = true; diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index 021c7c0..652b316 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -107,8 +107,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setCurrentOrganization(null); } } - } catch (err) { - console.error('Auth check failed:', err); + } catch (err: unknown) { + const status = + (err as { statusCode?: number })?.statusCode ?? + (err as { response?: { status?: number } })?.response?.status; + + // 401 on profile is expected when there is no session — not an application error. + if (status !== 401) { + console.error('Auth check failed:', err); + } + // Only clear state — DO NOT redirect here setUser(null); setOrganizations([]);