From 2f95559087561386dbce7d07e443d99d0aa9816c Mon Sep 17 00:00:00 2001 From: rameen Date: Thu, 2 Jul 2026 17:59:49 +0330 Subject: [PATCH] remember me with max age 30 days :). --- backend/src/modules/auth/auth.controller.ts | 93 +++++++++++++------ backend/src/modules/auth/dto/login.dto.ts | 11 ++- .../src/app/[locale]/(public)/login/page.tsx | 12 ++- frontend/src/lib/auth/rememberMe.ts | 14 +++ frontend/src/lib/hooks/useAuth.tsx | 25 ++++- frontend/src/types/auth.ts | 1 + 6 files changed, 120 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/auth/rememberMe.ts diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 92492c7..c3380ca 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -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 }): 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); } } \ No newline at end of file diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts index f098bbe..32df602 100644 --- a/backend/src/modules/auth/dto/login.dto.ts +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -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; } \ No newline at end of file diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index fc2f8ff..883cdbd 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -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(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({ 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() {