From 4c724da7ae67ce371db832d40c1e11a8a412e764 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 3 Feb 2026 19:21:48 +0900 Subject: [PATCH] feat: implement trusted device functionality for enhanced login experience --- apps/bff/src/infra/cache/cache.service.ts | 60 ++++ apps/bff/src/modules/auth/auth.module.ts | 4 + .../auth/infra/trusted-device/index.ts | 2 + .../trusted-device/trusted-device.service.ts | 300 ++++++++++++++++++ .../workflows/password-workflow.service.ts | 6 + .../auth/presentation/http/auth.controller.ts | 65 +++- .../http/utils/trusted-device-cookie.util.ts | 102 ++++++ .../auth/components/LoginForm/LoginForm.tsx | 4 +- .../components/LoginOtpStep/LoginOtpStep.tsx | 27 +- .../src/features/auth/hooks/use-auth.ts | 4 +- .../src/features/auth/stores/auth.store.ts | 11 +- packages/domain/auth/schema.ts | 2 + 12 files changed, 570 insertions(+), 17 deletions(-) create mode 100644 apps/bff/src/modules/auth/infra/trusted-device/index.ts create mode 100644 apps/bff/src/modules/auth/infra/trusted-device/trusted-device.service.ts create mode 100644 apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index b330f879..c3b4856a 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -287,6 +287,66 @@ export class CacheService { return fresh; } + // ============================================================================ + // Redis SET Operations (Atomic) + // ============================================================================ + + /** + * Add one or more members to a Redis set (SADD) + * This is atomic and safe for concurrent access. + * + * @param key Redis set key + * @param members Members to add to the set + * @returns Number of members added (excludes already existing members) + */ + async setAdd(key: string, ...members: string[]): Promise { + if (members.length === 0) { + return 0; + } + return this.redis.sadd(key, ...members); + } + + /** + * Remove one or more members from a Redis set (SREM) + * This is atomic and safe for concurrent access. + * + * @param key Redis set key + * @param members Members to remove from the set + * @returns Number of members removed + */ + async setRemove(key: string, ...members: string[]): Promise { + if (members.length === 0) { + return 0; + } + return this.redis.srem(key, ...members); + } + + /** + * Get all members of a Redis set (SMEMBERS) + * + * @param key Redis set key + * @returns Array of set members + */ + async setMembers(key: string): Promise { + return this.redis.smembers(key); + } + + /** + * Add a member to a set and set TTL on the set key + * Combines SADD and EXPIRE in a pipeline for efficiency. + * + * @param key Redis set key + * @param member Member to add + * @param ttlSeconds TTL in seconds to set/refresh on the key + */ + async setAddWithTtl(key: string, member: string, ttlSeconds: number): Promise { + const ttl = Math.max(1, Math.floor(ttlSeconds)); + const pipeline = this.redis.pipeline(); + pipeline.sadd(key, member); + pipeline.expire(key, ttl); + await pipeline.exec(); + } + /** * Scan keys matching a pattern and invoke callback for each batch * Uses cursor-based iteration for safe operation on large datasets diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 12662a87..e7475847 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -35,6 +35,8 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; // Login OTP flow import { LoginSessionService } from "./infra/login/login-session.service.js"; import { LoginOtpWorkflowService } from "./infra/workflows/login-otp-workflow.service.js"; +// Trusted device +import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.service.js"; @Module({ imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule, WorkflowModule], @@ -67,6 +69,8 @@ import { LoginOtpWorkflowService } from "./infra/workflows/login-otp-workflow.se // Login OTP flow services LoginSessionService, LoginOtpWorkflowService, + // Trusted device + TrustedDeviceService, // Guards and interceptors FailedLoginThrottleGuard, AuthRateLimitService, diff --git a/apps/bff/src/modules/auth/infra/trusted-device/index.ts b/apps/bff/src/modules/auth/infra/trusted-device/index.ts new file mode 100644 index 00000000..400bf4bb --- /dev/null +++ b/apps/bff/src/modules/auth/infra/trusted-device/index.ts @@ -0,0 +1,2 @@ +export { TrustedDeviceService } from "./trusted-device.service.js"; +export type { TrustedDeviceValidationResult } from "./trusted-device.service.js"; diff --git a/apps/bff/src/modules/auth/infra/trusted-device/trusted-device.service.ts b/apps/bff/src/modules/auth/infra/trusted-device/trusted-device.service.ts new file mode 100644 index 00000000..123183aa --- /dev/null +++ b/apps/bff/src/modules/auth/infra/trusted-device/trusted-device.service.ts @@ -0,0 +1,300 @@ +import { randomUUID } from "crypto"; + +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; + +import { CacheService } from "@/infra/cache/cache.service.js"; +import { JoseJwtService } from "../token/jose-jwt.service.js"; + +/** + * Trusted device token payload stored in JWT + * Extends JWTPayload to satisfy jose library constraints + */ +interface TrustedDeviceTokenPayload { + /** JWT subject - user ID */ + sub: string; + /** Unique device identifier */ + deviceId: string; + /** Purpose claim to distinguish from other token types */ + purpose: "trusted_device"; + /** Index signature for JWTPayload compatibility */ + [key: string]: unknown; +} + +/** + * Device record stored in Redis + */ +interface TrustedDeviceRecord { + /** User ID this device belongs to */ + userId: string; + /** Unique device identifier */ + deviceId: string; + /** When the device was trusted */ + createdAt: string; + /** User agent string for audit/display */ + userAgent?: string; + /** Whether this device trust is still valid */ + valid: boolean; +} + +/** + * Result of validating a trusted device token + */ +export interface TrustedDeviceValidationResult { + /** Whether the device is trusted and valid */ + valid: boolean; + /** User ID if valid */ + userId?: string; + /** Device ID if valid */ + deviceId?: string; + /** Reason for invalid result */ + reason?: "missing" | "expired" | "invalid_signature" | "revoked" | "user_mismatch"; +} + +/** + * Trusted Device Service + * + * Manages trusted device tokens for "Remember this device" functionality. + * When a user successfully completes OTP verification with "Remember this device" + * checked, a trusted device token is issued as a signed JWT stored in a cookie. + * + * On subsequent logins, if a valid trusted device cookie exists for the same user, + * OTP verification is skipped. + * + * Security: + * - Tokens are signed JWTs with purpose claim + * - Device records stored in Redis with TTL + * - Devices can be revoked via logout or manual revocation + * - User binding: token only valid for the user who created it + */ +@Injectable() +export class TrustedDeviceService { + private readonly DEVICE_PREFIX = "trusted-device:"; + private readonly USER_DEVICES_PREFIX = "user-devices:"; + private readonly ttlSeconds: number; + + constructor( + private readonly cache: CacheService, + private readonly jwtService: JoseJwtService, + private readonly config: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + // Default: 7 days (168 hours) + const ttlHours = this.config.get("TRUSTED_DEVICE_TTL_HOURS", 168); + this.ttlSeconds = ttlHours * 60 * 60; + } + + /** + * Create a trusted device token and store the device record + * + * @param userId - The user ID to associate with the device + * @param userAgent - Optional user agent for audit purposes + * @returns The signed JWT token to set in the cookie + */ + async createTrustedDevice(userId: string, userAgent?: string): Promise { + const deviceId = randomUUID(); + const createdAt = new Date().toISOString(); + + // Store device record in Redis + const deviceRecord: TrustedDeviceRecord = { + userId, + deviceId, + createdAt, + valid: true, + ...(userAgent !== undefined && { userAgent }), + }; + + await this.cache.set(this.buildDeviceKey(deviceId), deviceRecord, this.ttlSeconds); + + // Add to user's device set for bulk operations + await this.addToUserDeviceSet(userId, deviceId); + + // Create signed JWT token + const token = await this.jwtService.sign( + { + sub: userId, + deviceId, + purpose: "trusted_device", + } satisfies TrustedDeviceTokenPayload, + this.ttlSeconds + ); + + this.logger.debug({ userId, deviceId }, "Trusted device created"); + + return token; + } + + /** + * Validate a trusted device token + * + * @param token - The JWT token from the cookie + * @param expectedUserId - The user ID that should match the token (from credentials) + * @returns Validation result with user info if valid + */ + async validateTrustedDevice( + token: string | undefined, + expectedUserId: string + ): Promise { + if (!token) { + return { valid: false, reason: "missing" }; + } + + // Verify JWT signature and decode payload + let payload: TrustedDeviceTokenPayload; + try { + payload = await this.jwtService.verify(token); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + // Check if it's an expiry error + if (errorMessage.includes("exp") || errorMessage.includes("expired")) { + this.logger.debug({ error: errorMessage }, "Trusted device token expired"); + return { valid: false, reason: "expired" }; + } + this.logger.debug({ error: errorMessage }, "Trusted device token invalid"); + return { valid: false, reason: "invalid_signature" }; + } + + // Validate purpose claim + if (payload.purpose !== "trusted_device") { + this.logger.warn({ purpose: payload.purpose }, "Invalid token purpose for trusted device"); + return { valid: false, reason: "invalid_signature" }; + } + + // Check user binding - token must be for the same user attempting login + if (payload.sub !== expectedUserId) { + this.logger.debug( + { tokenUserId: payload.sub, expectedUserId }, + "Trusted device token user mismatch" + ); + return { valid: false, reason: "user_mismatch" }; + } + + // Check if device is still valid in Redis (not revoked) + const deviceRecord = await this.cache.get( + this.buildDeviceKey(payload.deviceId) + ); + + if (!deviceRecord) { + this.logger.debug({ deviceId: payload.deviceId }, "Trusted device not found in cache"); + return { valid: false, reason: "revoked" }; + } + + if (!deviceRecord.valid) { + this.logger.debug({ deviceId: payload.deviceId }, "Trusted device has been revoked"); + return { valid: false, reason: "revoked" }; + } + + // Verify user ID matches (defense in depth) + if (deviceRecord.userId !== expectedUserId) { + this.logger.warn( + { recordUserId: deviceRecord.userId, expectedUserId }, + "Trusted device record user mismatch" + ); + return { valid: false, reason: "user_mismatch" }; + } + + this.logger.debug( + { userId: payload.sub, deviceId: payload.deviceId }, + "Trusted device validated successfully" + ); + + return { + valid: true, + userId: payload.sub, + deviceId: payload.deviceId, + }; + } + + /** + * Revoke a specific trusted device + * + * @param deviceId - The device ID to revoke + */ + async revokeDevice(deviceId: string): Promise { + const deviceRecord = await this.cache.get(this.buildDeviceKey(deviceId)); + + if (deviceRecord) { + // Mark as invalid rather than delete (for audit trail) + deviceRecord.valid = false; + await this.cache.set(this.buildDeviceKey(deviceId), deviceRecord, this.ttlSeconds); + + // Remove from user's device set + await this.removeFromUserDeviceSet(deviceRecord.userId, deviceId); + + this.logger.debug({ deviceId, userId: deviceRecord.userId }, "Trusted device revoked"); + } + } + + /** + * Revoke all trusted devices for a user + * Called on logout or password change + * + * @param userId - The user ID whose devices should be revoked + */ + async revokeAllUserDevices(userId: string): Promise { + const deviceIds = await this.getUserDeviceIds(userId); + + for (const deviceId of deviceIds) { + // eslint-disable-next-line no-await-in-loop -- Sequential deletion ensures clean revocation + await this.cache.del(this.buildDeviceKey(deviceId)); + } + + // Clear the user's device set + await this.cache.del(this.buildUserDevicesKey(userId)); + + this.logger.debug( + { userId, deviceCount: deviceIds.length }, + "All trusted devices revoked for user" + ); + } + + /** + * Get the TTL in milliseconds (for cookie maxAge) + */ + getTtlMs(): number { + return this.ttlSeconds * 1000; + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Build Redis key for device record + */ + private buildDeviceKey(deviceId: string): string { + return `${this.DEVICE_PREFIX}${deviceId}`; + } + + /** + * Build Redis key for user's device set + */ + private buildUserDevicesKey(userId: string): string { + return `${this.USER_DEVICES_PREFIX}${userId}`; + } + + /** + * Add device to user's device set (atomic operation using Redis SADD) + */ + private async addToUserDeviceSet(userId: string, deviceId: string): Promise { + const key = this.buildUserDevicesKey(userId); + await this.cache.setAddWithTtl(key, deviceId, this.ttlSeconds); + } + + /** + * Remove device from user's device set (atomic operation using Redis SREM) + */ + private async removeFromUserDeviceSet(userId: string, deviceId: string): Promise { + const key = this.buildUserDevicesKey(userId); + await this.cache.setRemove(key, deviceId); + } + + /** + * Get all device IDs for a user (using Redis SMEMBERS) + */ + private async getUserDeviceIds(userId: string): Promise { + const key = this.buildUserDevicesKey(userId); + return this.cache.setMembers(key); + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts index b5ea51cf..aa0d0aa1 100644 --- a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts @@ -16,6 +16,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { AuthTokenService } from "../token/token.service.js"; import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js"; import { PasswordResetTokenService } from "../token/password-reset-token.service.js"; +import { TrustedDeviceService } from "../trusted-device/trusted-device.service.js"; import { type ChangePasswordRequest, changePasswordRequestSchema, @@ -34,6 +35,7 @@ export class PasswordWorkflowService { private readonly passwordResetTokenService: PasswordResetTokenService, private readonly tokenService: AuthTokenService, private readonly authRateLimitService: AuthRateLimitService, + private readonly trustedDeviceService: TrustedDeviceService, @Inject(Logger) private readonly logger: Logger ) {} @@ -163,6 +165,8 @@ export class PasswordWorkflowService { } // Force re-login everywhere after password reset await this.tokenService.revokeAllUserTokens(freshUser.id); + // Revoke all trusted devices - attacker could have set up a trusted device + await this.trustedDeviceService.revokeAllUserDevices(freshUser.id); }, this.logger, { @@ -228,6 +232,8 @@ export class PasswordWorkflowService { // Revoke existing refresh tokens before issuing new pair (logout other sessions) await this.tokenService.revokeAllUserTokens(userProfile.id); + // Revoke all trusted devices - user is changing password, should re-verify devices + await this.trustedDeviceService.revokeAllUserDevices(userProfile.id); const tokens = await this.tokenService.generateTokenPair({ id: userProfile.id, diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index b4d3f7bc..5407b2bd 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -36,7 +36,13 @@ import { REFRESH_COOKIE_PATH, TOKEN_TYPE, } from "./utils/auth-cookie.util.js"; +import { + setTrustedDeviceCookie, + clearTrustedDeviceCookie, + getTrustedDeviceToken, +} from "./utils/trusted-device-cookie.util.js"; import { devAuthConfig } from "@bff/core/config/auth-dev.config.js"; +import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device.service.js"; // Import Zod schemas from domain import { @@ -82,7 +88,8 @@ export class AuthController { constructor( private authOrchestrator: AuthOrchestrator, private readonly jwtService: JoseJwtService, - private readonly loginOtpWorkflow: LoginOtpWorkflowService + private readonly loginOtpWorkflow: LoginOtpWorkflowService, + private readonly trustedDeviceService: TrustedDeviceService ) {} private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void { @@ -119,16 +126,18 @@ export class AuthController { * POST /auth/login - Initiate login with credentials * * After valid credential check: - * 1. Generates OTP and sends to user's email - * 2. Returns session token for OTP verification - * 3. User must call /auth/login/verify-otp to complete login + * 1. Check if device is trusted (has valid trusted device cookie for this user) + * 2. If trusted, skip OTP and complete login directly + * 3. Otherwise, generate OTP and send to user's email + * 4. Return session token for OTP verification + * 5. User must call /auth/login/verify-otp to complete login */ @Public() @UseGuards(LocalAuthGuard, FailedLoginThrottleGuard) @UseInterceptors(LoginResultInterceptor) @Post("login") async login( - @Req() req: RequestWithUser & RequestWithRateLimit, + @Req() req: RequestWithUser & RequestWithRateLimit & RequestWithCookies, @Res({ passthrough: true }) res: Response ) { this.applyAuthRateLimitHeaders(req, res); @@ -146,6 +155,26 @@ export class AuthController { }; } + // Check if this is a trusted device for the authenticated user + const trustedDeviceToken = getTrustedDeviceToken(req); + const trustedDeviceResult = await this.trustedDeviceService.validateTrustedDevice( + trustedDeviceToken, + req.user.id + ); + + // If device is trusted for this user, skip OTP and complete login + if (trustedDeviceResult.valid) { + const loginResult = await this.authOrchestrator.completeLogin( + { id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" }, + req + ); + setAuthCookies(res, loginResult.tokens); + return { + user: loginResult.user, + session: buildSessionInfo(loginResult.tokens), + }; + } + // Credentials validated by LocalAuthGuard - now initiate OTP const fingerprint = getRequestFingerprint(req); const otpResult = await this.loginOtpWorkflow.initiateOtp( @@ -170,6 +199,7 @@ export class AuthController { * POST /auth/login/verify-otp - Complete login with OTP verification * * Verifies the OTP code and issues auth tokens on success. + * If rememberDevice is true, sets a trusted device cookie to skip OTP on future logins. */ @Public() @Post("login/verify-otp") @@ -196,6 +226,17 @@ export class AuthController { setAuthCookies(res, loginResult.tokens); + // If user wants to remember this device, create and set trusted device cookie + if (body.rememberDevice) { + const rawUserAgent = req.headers["user-agent"]; + const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; + const trustedDeviceToken = await this.trustedDeviceService.createTrustedDevice( + result.userId, + userAgent + ); + setTrustedDeviceCookie(res, trustedDeviceToken, this.trustedDeviceService.getTtlMs()); + } + return { user: loginResult.user, session: buildSessionInfo(loginResult.tokens), @@ -218,10 +259,24 @@ export class AuthController { } } + // Revoke trusted device for this user if they have one + const trustedDeviceToken = getTrustedDeviceToken(req); + if (trustedDeviceToken && userId) { + // Validate to get device ID, then revoke + const validation = await this.trustedDeviceService.validateTrustedDevice( + trustedDeviceToken, + userId + ); + if (validation.deviceId) { + await this.trustedDeviceService.revokeDevice(validation.deviceId); + } + } + await this.authOrchestrator.logout(userId, token, req as Request); // Always clear cookies, even if session expired clearAuthCookies(res); + clearTrustedDeviceCookie(res); return { message: "Logout successful" }; } diff --git a/apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts b/apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts new file mode 100644 index 00000000..8f36ba54 --- /dev/null +++ b/apps/bff/src/modules/auth/presentation/http/utils/trusted-device-cookie.util.ts @@ -0,0 +1,102 @@ +import type { Request, Response, CookieOptions } from "express"; + +/** + * Cookie name for trusted device token + */ +export const TRUSTED_DEVICE_COOKIE_NAME = "trusted_device"; + +/** + * Cookie path - applies to all auth endpoints + */ +export const TRUSTED_DEVICE_COOKIE_PATH = "/api/auth"; + +/** + * Custom setSecureCookie function signature + * This is added by our security middleware + */ +type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void; + +/** + * Get setSecureCookie function from response if available + * Returns null if the custom helper is not present + */ +function getSecureCookie(res: Response): SetSecureCookieFn | null { + const maybeSecure = res as Response & { setSecureCookie?: unknown }; + if (typeof maybeSecure.setSecureCookie === "function") { + return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn; + } + return null; +} + +/** + * Set the trusted device cookie on the response + * + * @param res - Express response object + * @param token - The signed JWT token + * @param ttlMs - Time to live in milliseconds + */ +export function setTrustedDeviceCookie(res: Response, token: string, ttlMs: number): void { + const setSecureCookie = getSecureCookie(res); + + if (setSecureCookie) { + // Use the custom setSecureCookie helper if available + setSecureCookie(TRUSTED_DEVICE_COOKIE_NAME, token, { + maxAge: ttlMs, + path: TRUSTED_DEVICE_COOKIE_PATH, + }); + } else { + // Fallback to standard cookie with secure options + const isProduction = process.env["NODE_ENV"] === "production"; + + res.cookie(TRUSTED_DEVICE_COOKIE_NAME, token, { + httpOnly: true, + secure: isProduction, + sameSite: "lax", + path: TRUSTED_DEVICE_COOKIE_PATH, + maxAge: ttlMs, + }); + } +} + +/** + * Clear the trusted device cookie + * + * @param res - Express response object + */ +export function clearTrustedDeviceCookie(res: Response): void { + const setSecureCookie = getSecureCookie(res); + + if (setSecureCookie) { + setSecureCookie(TRUSTED_DEVICE_COOKIE_NAME, "", { + maxAge: 0, + path: TRUSTED_DEVICE_COOKIE_PATH, + }); + } else { + const isProduction = process.env["NODE_ENV"] === "production"; + + res.cookie(TRUSTED_DEVICE_COOKIE_NAME, "", { + httpOnly: true, + secure: isProduction, + sameSite: "lax", + path: TRUSTED_DEVICE_COOKIE_PATH, + maxAge: 0, + }); + } +} + +/** + * Request type with cookies + */ +type RequestWithCookies = Omit & { + cookies?: Record; +}; + +/** + * Get the trusted device token from the request cookies + * + * @param req - Express request object + * @returns The token string or undefined if not present + */ +export function getTrustedDeviceToken(req: RequestWithCookies): string | undefined { + return req.cookies?.[TRUSTED_DEVICE_COOKIE_NAME]; +} diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index f3542437..cb293069 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -103,13 +103,13 @@ export function LoginForm({ ); const handleOtpVerify = useCallback( - async (code: string) => { + async (code: string, rememberDevice: boolean) => { if (!otpState) return; // Note: Don't call clearError() here - the store already sets error: null // at the start of verifyLoginOtp, which ensures atomic state updates try { - await verifyOtp(otpState.sessionToken, code); + await verifyOtp(otpState.sessionToken, code, rememberDevice); onSuccess?.(); } catch (err) { const message = err instanceof Error ? err.message : "Verification failed"; diff --git a/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx b/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx index 051c32e7..71b991b1 100644 --- a/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx +++ b/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx @@ -16,7 +16,7 @@ interface LoginOtpStepProps { sessionToken: string; maskedEmail: string; expiresAt: string; - onVerify: (code: string) => Promise; + onVerify: (code: string, rememberDevice: boolean) => Promise; onBack: () => void; loading?: boolean; error?: string | null; @@ -43,6 +43,7 @@ export function LoginOtpStep({ const [code, setCode] = useState(""); const [isVerifying, setIsVerifying] = useState(false); const [timeRemaining, setTimeRemaining] = useState(null); + const [rememberDevice, setRememberDevice] = useState(false); // Calculate initial time remaining with validation useEffect(() => { @@ -82,11 +83,11 @@ export function LoginOtpStep({ setIsVerifying(true); try { - await onVerify(code); + await onVerify(code, rememberDevice); } finally { setIsVerifying(false); } - }, [code, isVerifying, loading, onVerify]); + }, [code, isVerifying, loading, onVerify, rememberDevice]); const handleComplete = useCallback( (completedCode: string) => { @@ -96,14 +97,14 @@ export function LoginOtpStep({ void (async () => { setIsVerifying(true); try { - await onVerify(completedCode); + await onVerify(completedCode, rememberDevice); } finally { setIsVerifying(false); } })(); } }, - [isVerifying, loading, onVerify] + [isVerifying, loading, onVerify, rememberDevice] ); const isExpired = timeRemaining !== null && timeRemaining <= 0; @@ -148,6 +149,22 @@ export function LoginOtpStep({ )} + {/* Remember Device Checkbox */} +
+ setRememberDevice(e.target.checked)} + disabled={isSubmitting || isExpired} + className="h-4 w-4 text-primary focus:ring-primary border-border rounded transition-colors accent-primary" + /> + +
+ {/* Verify Button */}