Compare commits

...

7 Commits

Author SHA1 Message Date
2f95559087 remember me with max age 30 days :). 2026-07-02 17:59:49 +03:30
653d67e15b Merge pull request 'fix auth refresh endpoint and quiet expected 401 on profile check' (#50) from bugfix/getprofile-error into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/50
Reviewed-by: admin <admin@localhost>
2026-06-26 18:01:14 +03:30
c1cffbcafa fix auth refresh endpoint and quiet expected 401 on profile check 2026-06-26 13:23:03 +03:30
6738ae225d Merge pull request 'bugfix/tab-size-issue' (#49) from bugfix/tab-size-issue into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 1s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/49
Reviewed-by: admin <admin@localhost>
2026-06-25 11:56:47 +03:30
db4803d463 fix sidebar width change during toggling between tabs. 2026-06-24 17:09:06 +03:30
740f6d9cc9 test disable push on master 2026-06-23 15:54:13 +03:30
36cf991684 Merge pull request 'feature/tab-warning-flag' (#44) from feature/tab-warning-flag into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/44
2026-06-22 14:44:19 +03:30
12 changed files with 176 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
# 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`**.

View File

@@ -11,6 +11,7 @@ import {
HttpStatus,
Get,
Patch,
UnauthorizedException,
} from '@nestjs/common';
import type { Response } from 'express';
import {
@@ -34,6 +35,8 @@ import { UpdateLanguageDto } from './dto/update-language.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
constructor(private readonly authService: AuthService) {}
// =========================
@@ -55,9 +58,14 @@ export class AuthController {
console.log('Login endpoint hit');
const result = await this.authService.login(loginDto, req.user);
const rememberMe = Boolean(loginDto.rememberMe);
// ✅ SET COOKIES HERE
this.setAuthCookies(res, result.data.accessToken, result.data.refreshToken);
this.setAuthCookies(
res,
result.data.accessToken,
result.data.refreshToken,
rememberMe,
);
return {
success: true,
@@ -112,8 +120,11 @@ export class AuthController {
organizationId
);
// 🔥 Replace access token with org-scoped token
this.setAccessToken(res, result.data.accessToken);
this.setAccessToken(
res,
result.data.accessToken,
this.isPersistentSession(req),
);
return {
success: true,
@@ -173,6 +184,37 @@ 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,
this.isPersistentSession(req),
);
return {
success: true,
data: {
accessToken: result.data.accessToken,
},
};
}
// =========================
// LOGOUT
// =========================
@@ -207,45 +249,64 @@ export class AuthController {
// =========================
// 🔥 COOKIE HELPERS
// =========================
private isPersistentSession(req: { cookies?: Record<string, string> }): boolean {
return req?.cookies?.authRemember === '1';
}
private baseCookieOptions() {
return {
httpOnly: true,
secure: false, // ⚠️ true in production (HTTPS)
sameSite: 'lax' as const,
path: '/',
};
}
private setAuthCookies(
res: Response,
accessToken: string,
refreshToken: string
refreshToken: string,
rememberMe = false,
) {
this.setAccessToken(res, accessToken);
this.setRefreshToken(res, refreshToken);
this.setAccessToken(res, accessToken, rememberMe);
this.setRefreshToken(res, refreshToken, rememberMe);
this.setRememberMeFlag(res, rememberMe);
}
private setAccessToken(res: Response, token: string) {
private setAccessToken(res: Response, token: string, rememberMe = false) {
res.cookie('accessToken', token, {
httpOnly: true,
secure: false, // ⚠️ true in production (HTTPS)
sameSite: 'lax',
path: '/',
...this.baseCookieOptions(),
...(rememberMe
? { maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS }
: {}),
});
}
private setRefreshToken(res: Response, token: string) {
private setRefreshToken(res: Response, token: string, rememberMe = false) {
res.cookie('refreshToken', token, {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
...this.baseCookieOptions(),
...(rememberMe
? { maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS }
: {}),
});
}
private setRememberMeFlag(res: Response, rememberMe: boolean) {
if (rememberMe) {
res.cookie('authRemember', '1', {
...this.baseCookieOptions(),
maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS,
});
return;
}
res.clearCookie('authRemember', this.baseCookieOptions());
}
private clearAuthCookies(res: Response) {
res.clearCookie('accessToken', {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
});
res.clearCookie('refreshToken', {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
});
const options = this.baseCookieOptions();
res.clearCookie('accessToken', options);
res.clearCookie('refreshToken', options);
res.clearCookie('authRemember', options);
}
}

View File

@@ -1,5 +1,5 @@
// backend/src/modules/auth/dto/login.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@@ -20,4 +20,13 @@ export class LoginDto {
@IsString()
@MinLength(6, { message: 'Password must be at least 6 characters long' })
password: string;
@ApiProperty({
description: 'Keep the user signed in for 30 days on this device',
required: false,
default: false,
})
@IsOptional()
@IsBoolean()
rememberMe?: boolean;
}

View File

@@ -9,6 +9,7 @@ import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { Mail, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { getRememberedEmail } from '@/lib/auth/rememberMe';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
@@ -16,6 +17,7 @@ import { TopBarControls } from '@/components/ui/shared/TopBarControls';
type LoginForm = {
email: string;
password: string;
rememberMe: boolean;
};
export default function LoginPage() {
@@ -25,12 +27,14 @@ export default function LoginPage() {
const { login, isLoading, user, isAuthReady } = useAuth();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [savedEmail] = useState(() => getRememberedEmail());
const loginSchema = useMemo(
() =>
z.object({
email: z.string().email(tValidation('emailInvalid')),
password: z.string().min(1, tValidation('passwordRequired')),
rememberMe: z.boolean(),
}),
[tValidation],
);
@@ -47,12 +51,16 @@ export default function LoginPage() {
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: savedEmail,
rememberMe: Boolean(savedEmail),
},
});
const onSubmit = async (data: LoginForm) => {
try {
setError(null);
await login(data.email, data.password);
await login(data.email, data.password, data.rememberMe);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('invalidCredentials');
setError(message || t('invalidCredentials'));
@@ -112,9 +120,9 @@ export default function LoginPage() {
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 rounded border-border bg-background-secondary text-primary focus:ring-primary/40"
{...register('rememberMe')}
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-text-secondary">
{t('rememberMe')}

View File

@@ -53,7 +53,7 @@ export function InvitationHistoryDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<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"
aria-modal="true"
aria-labelledby="invitation-history-title"

View File

@@ -107,7 +107,7 @@ export function CreatePatientModal({
}}
>
<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"
aria-modal="true"
aria-labelledby="create-patient-dialog-title"

View File

@@ -59,7 +59,7 @@ function Sidebar() {
);
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">
<h1 className="text-lg font-medium tracking-tight">{tCommon('appName')}</h1>
</div>

View File

@@ -66,7 +66,7 @@ export function TreatmentPreviewDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<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"
aria-modal="true"
aria-labelledby="treatment-preview-title"

View File

@@ -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;

View File

@@ -0,0 +1,14 @@
const REMEMBERED_EMAIL_KEY = 'rememberedEmail';
export function getRememberedEmail(): string {
if (typeof window === 'undefined') return '';
return localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '';
}
export function setRememberedEmail(email: string): void {
localStorage.setItem(REMEMBERED_EMAIL_KEY, email);
}
export function clearRememberedEmail(): void {
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
}

View File

@@ -4,6 +4,11 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { authApi } from '@/lib/api/auth';
import {
clearRememberedEmail,
getRememberedEmail,
setRememberedEmail,
} from '@/lib/auth/rememberMe';
import { User, Organization } from '@/types/organization';
import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing';
@@ -22,7 +27,7 @@ interface AuthContextType {
organizationEmail: string,
organizationType: 'CLINIC' | 'LAB'
) => Promise<void>;
login: (email: string, password: string) => Promise<void>;
login: (email: string, password: string, rememberMe?: boolean) => Promise<void>;
logout: () => Promise<void>;
selectOrganization: (orgId: string) => Promise<void>;
createOrganization: (
@@ -107,8 +112,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([]);
@@ -188,12 +201,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, [applyUrlLocaleToUser, router, t]);
// ✅ LOGIN
const login = useCallback(async (email: string, password: string) => {
const login = useCallback(async (
email: string,
password: string,
rememberMe = false,
) => {
try {
setIsLoading(true);
setError(null);
const response = await authApi.login({ email, password });
const response = await authApi.login({ email, password, rememberMe });
if (rememberMe) {
setRememberedEmail(email);
} else {
clearRememberedEmail();
}
const userData = await applyUrlLocaleToUser(response.data.user);
setUser(userData);
@@ -227,7 +250,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch (err) {
console.error('Logout API failed:', err);
} finally {
const rememberedEmail = getRememberedEmail();
localStorage.clear();
if (rememberedEmail) {
setRememberedEmail(rememberedEmail);
}
setUser(null);
setOrganizations([]);
setCurrentOrganization(null);

View File

@@ -22,4 +22,5 @@ export interface TrialRegistrationData {
export interface LoginData {
email: string;
password: string;
rememberMe?: boolean;
}