Compare commits
5 Commits
feature/ta
...
bugfix/get
| Author | SHA1 | Date | |
|---|---|---|---|
| c1cffbcafa | |||
| 6738ae225d | |||
| db4803d463 | |||
| 740f6d9cc9 | |||
| 36cf991684 |
@@ -1,6 +1,6 @@
|
|||||||
# Dyolink
|
# Dyolink
|
||||||
|
|
||||||
Monorepo: **NestJS** backend (`backend/`), **Next.js** frontend (`frontend/`), **Docker** stack under `infrastructure/`.
|
Monorepo: **NestJS** backend (`backend/`), **Next.js** frontend (`frontend/`), **Docker** stack under `infrastructure/`..
|
||||||
|
|
||||||
Local development: see **`backend/README.md`** and **`frontend/README.md`**.
|
Local development: see **`backend/README.md`** and **`frontend/README.md`**.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Get,
|
Get,
|
||||||
Patch,
|
Patch,
|
||||||
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import {
|
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
|
// LOGOUT
|
||||||
// =========================
|
// =========================
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function InvitationHistoryDialog({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-[min(56rem,calc(100vw-17rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
|
className="w-full max-w-[min(56rem,calc(100vw-15rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="invitation-history-title"
|
aria-labelledby="invitation-history-title"
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function CreatePatientModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="surface-card w-full max-w-[min(56rem,calc(100vw-17rem))] p-5 space-y-4 shadow-xl"
|
className="surface-card w-full max-w-[min(56rem,calc(100vw-15rem))] p-5 space-y-4 shadow-xl"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="create-patient-dialog-title"
|
aria-labelledby="create-patient-dialog-title"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function Sidebar() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
|
<aside className="w-56 min-w-56 shrink-0 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
|
||||||
<div className="h-[71px] px-4 flex items-center">
|
<div className="h-[71px] px-4 flex items-center">
|
||||||
<h1 className="text-lg font-medium tracking-tight">{tCommon('appName')}</h1>
|
<h1 className="text-lg font-medium tracking-tight">{tCommon('appName')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function TreatmentPreviewDialog({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-[min(56rem,calc(100vw-17rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
|
className="w-full max-w-[min(56rem,calc(100vw-15rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="treatment-preview-title"
|
aria-labelledby="treatment-preview-title"
|
||||||
|
|||||||
@@ -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)
|
// ❌ REMOVE request interceptor completely (no Authorization header)
|
||||||
|
|
||||||
// ✅ Response interceptor
|
// ✅ Response interceptor
|
||||||
@@ -37,7 +49,8 @@ apiClient.interceptors.response.use(
|
|||||||
if (
|
if (
|
||||||
error.response?.status === 401 &&
|
error.response?.status === 401 &&
|
||||||
!originalRequest._retry &&
|
!originalRequest._retry &&
|
||||||
!isPublicInvitationRequest(originalRequest.url)
|
!isPublicInvitationRequest(originalRequest.url) &&
|
||||||
|
!shouldSkipRefreshRetry(originalRequest.url)
|
||||||
) {
|
) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
|||||||
@@ -107,8 +107,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setCurrentOrganization(null);
|
setCurrentOrganization(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (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);
|
console.error('Auth check failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
// Only clear state — DO NOT redirect here
|
// Only clear state — DO NOT redirect here
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user