remember me with max age 30 days :). #51

Merged
admin merged 1 commits from feature/remembre-password into master 2026-07-03 14:36:22 +03:30
6 changed files with 120 additions and 36 deletions
Showing only changes of commit 2f95559087 - Show all commits

View File

@@ -35,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) {}
// =========================
@@ -56,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,
@@ -113,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,
@@ -191,7 +201,11 @@ export class AuthController {
const result = await this.authService.refreshToken(refreshToken);
this.setAccessToken(res, result.data.accessToken);
this.setAccessToken(
res,
result.data.accessToken,
this.isPersistentSession(req),
);
return {
success: true,
@@ -235,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

@@ -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: (
@@ -196,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);
@@ -235,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;
}