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') @ApiTags('auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
// ========================= // =========================
@@ -56,9 +58,14 @@ export class AuthController {
console.log('Login endpoint hit'); console.log('Login endpoint hit');
const result = await this.authService.login(loginDto, req.user); const result = await this.authService.login(loginDto, req.user);
const rememberMe = Boolean(loginDto.rememberMe);
// ✅ SET COOKIES HERE this.setAuthCookies(
this.setAuthCookies(res, result.data.accessToken, result.data.refreshToken); res,
result.data.accessToken,
result.data.refreshToken,
rememberMe,
);
return { return {
success: true, success: true,
@@ -113,8 +120,11 @@ export class AuthController {
organizationId organizationId
); );
// 🔥 Replace access token with org-scoped token this.setAccessToken(
this.setAccessToken(res, result.data.accessToken); res,
result.data.accessToken,
this.isPersistentSession(req),
);
return { return {
success: true, success: true,
@@ -191,7 +201,11 @@ export class AuthController {
const result = await this.authService.refreshToken(refreshToken); const result = await this.authService.refreshToken(refreshToken);
this.setAccessToken(res, result.data.accessToken); this.setAccessToken(
res,
result.data.accessToken,
this.isPersistentSession(req),
);
return { return {
success: true, success: true,
@@ -235,45 +249,64 @@ export class AuthController {
// ========================= // =========================
// 🔥 COOKIE HELPERS // 🔥 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( private setAuthCookies(
res: Response, res: Response,
accessToken: string, accessToken: string,
refreshToken: string refreshToken: string,
rememberMe = false,
) { ) {
this.setAccessToken(res, accessToken); this.setAccessToken(res, accessToken, rememberMe);
this.setRefreshToken(res, refreshToken); 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, { res.cookie('accessToken', token, {
httpOnly: true, ...this.baseCookieOptions(),
secure: false, // ⚠️ true in production (HTTPS) ...(rememberMe
sameSite: 'lax', ? { maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS }
path: '/', : {}),
}); });
} }
private setRefreshToken(res: Response, token: string) { private setRefreshToken(res: Response, token: string, rememberMe = false) {
res.cookie('refreshToken', token, { res.cookie('refreshToken', token, {
httpOnly: true, ...this.baseCookieOptions(),
secure: false, ...(rememberMe
sameSite: 'lax', ? { maxAge: AuthController.REMEMBER_ME_MAX_AGE_MS }
path: '/', : {}),
}); });
} }
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) { private clearAuthCookies(res: Response) {
res.clearCookie('accessToken', { const options = this.baseCookieOptions();
httpOnly: true, res.clearCookie('accessToken', options);
secure: false, res.clearCookie('refreshToken', options);
sameSite: 'lax', res.clearCookie('authRemember', options);
path: '/',
});
res.clearCookie('refreshToken', {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
});
} }
} }

View File

@@ -1,5 +1,5 @@
// backend/src/modules/auth/dto/login.dto.ts // 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'; import { ApiProperty } from '@nestjs/swagger';
export class LoginDto { export class LoginDto {
@@ -20,4 +20,13 @@ export class LoginDto {
@IsString() @IsString()
@MinLength(6, { message: 'Password must be at least 6 characters long' }) @MinLength(6, { message: 'Password must be at least 6 characters long' })
password: string; 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 { Link } from '@/i18n/navigation';
import { Mail, Lock } from 'lucide-react'; import { Mail, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { getRememberedEmail } from '@/lib/auth/rememberMe';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input'; import { Input } from '@/components/ui/shared/Input';
import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { TopBarControls } from '@/components/ui/shared/TopBarControls';
@@ -16,6 +17,7 @@ import { TopBarControls } from '@/components/ui/shared/TopBarControls';
type LoginForm = { type LoginForm = {
email: string; email: string;
password: string; password: string;
rememberMe: boolean;
}; };
export default function LoginPage() { export default function LoginPage() {
@@ -25,12 +27,14 @@ export default function LoginPage() {
const { login, isLoading, user, isAuthReady } = useAuth(); const { login, isLoading, user, isAuthReady } = useAuth();
const router = useRouter(); const router = useRouter();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [savedEmail] = useState(() => getRememberedEmail());
const loginSchema = useMemo( const loginSchema = useMemo(
() => () =>
z.object({ z.object({
email: z.string().email(tValidation('emailInvalid')), email: z.string().email(tValidation('emailInvalid')),
password: z.string().min(1, tValidation('passwordRequired')), password: z.string().min(1, tValidation('passwordRequired')),
rememberMe: z.boolean(),
}), }),
[tValidation], [tValidation],
); );
@@ -47,12 +51,16 @@ export default function LoginPage() {
formState: { errors }, formState: { errors },
} = useForm<LoginForm>({ } = useForm<LoginForm>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: {
email: savedEmail,
rememberMe: Boolean(savedEmail),
},
}); });
const onSubmit = async (data: LoginForm) => { const onSubmit = async (data: LoginForm) => {
try { try {
setError(null); setError(null);
await login(data.email, data.password); await login(data.email, data.password, data.rememberMe);
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : t('invalidCredentials'); const message = err instanceof Error ? err.message : t('invalidCredentials');
setError(message || t('invalidCredentials')); setError(message || t('invalidCredentials'));
@@ -112,9 +120,9 @@ export default function LoginPage() {
<div className="flex items-center"> <div className="flex items-center">
<input <input
id="remember-me" id="remember-me"
name="remember-me"
type="checkbox" type="checkbox"
className="h-4 w-4 rounded border-border bg-background-secondary text-primary focus:ring-primary/40" 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"> <label htmlFor="remember-me" className="ml-2 block text-sm text-text-secondary">
{t('rememberMe')} {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 { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import {
clearRememberedEmail,
getRememberedEmail,
setRememberedEmail,
} from '@/lib/auth/rememberMe';
import { User, Organization } from '@/types/organization'; import { User, Organization } from '@/types/organization';
import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing'; import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing';
@@ -22,7 +27,7 @@ interface AuthContextType {
organizationEmail: string, organizationEmail: string,
organizationType: 'CLINIC' | 'LAB' organizationType: 'CLINIC' | 'LAB'
) => Promise<void>; ) => Promise<void>;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string, rememberMe?: boolean) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
selectOrganization: (orgId: string) => Promise<void>; selectOrganization: (orgId: string) => Promise<void>;
createOrganization: ( createOrganization: (
@@ -196,12 +201,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, [applyUrlLocaleToUser, router, t]); }, [applyUrlLocaleToUser, router, t]);
// ✅ LOGIN // ✅ LOGIN
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (
email: string,
password: string,
rememberMe = false,
) => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); 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); const userData = await applyUrlLocaleToUser(response.data.user);
setUser(userData); setUser(userData);
@@ -235,7 +250,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch (err) { } catch (err) {
console.error('Logout API failed:', err); console.error('Logout API failed:', err);
} finally { } finally {
const rememberedEmail = getRememberedEmail();
localStorage.clear(); localStorage.clear();
if (rememberedEmail) {
setRememberedEmail(rememberedEmail);
}
setUser(null); setUser(null);
setOrganizations([]); setOrganizations([]);
setCurrentOrganization(null); setCurrentOrganization(null);

View File

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