import { Controller, Post, Patch, Body, UseGuards, Request, Get, Res, Query, HttpCode, ForbiddenException, BadRequestException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { Throttle } from '@nestjs/throttler'; import { Response } from 'express'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { SwitchOrgDto } from './dto/switch-org.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; const COOKIE_NAME = 'ledgeriq_rt'; const isProduction = process.env.NODE_ENV === 'production'; const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true'; function setRefreshCookie(res: Response, token: string) { res.cookie(COOKIE_NAME, token, { httpOnly: true, secure: isProduction, sameSite: 'strict', path: '/api/auth', maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }); } function clearRefreshCookie(res: Response) { res.clearCookie(COOKIE_NAME, { httpOnly: true, secure: isProduction, sameSite: 'strict', path: '/api/auth', }); } @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} @Post('register') @ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' }) @Throttle({ default: { limit: 5, ttl: 60000 } }) async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { if (!isOpenRegistration) { throw new ForbiddenException( 'Open registration is disabled. Please use an invitation link to create your account.', ); } const result = await this.authService.register(dto); if (result.refreshToken) { setRefreshCookie(res, result.refreshToken); } const { refreshToken, ...response } = result; return response; } @Post('login') @ApiOperation({ summary: 'Login with email and password' }) @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseGuards(AuthGuard('local')) async login(@Request() req: any, @Body() _dto: LoginDto, @Res({ passthrough: true }) res: Response) { const ip = req.headers['x-forwarded-for'] || req.ip; const ua = req.headers['user-agent']; const result = await this.authService.login(req.user, ip, ua); // MFA challenge — no cookie, just return the challenge token if ('mfaRequired' in result) { return result; } if ('refreshToken' in result && result.refreshToken) { setRefreshCookie(res, result.refreshToken); } const { refreshToken: _rt, ...response } = result as any; return response; } @Post('refresh') @ApiOperation({ summary: 'Refresh access token using httpOnly cookie' }) async refresh(@Request() req: any, @Res({ passthrough: true }) res: Response) { const rawToken = req.cookies?.[COOKIE_NAME]; if (!rawToken) { throw new BadRequestException('No refresh token'); } return this.authService.refreshAccessToken(rawToken); } @Post('logout') @ApiOperation({ summary: 'Logout and revoke refresh token' }) @HttpCode(200) async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { const rawToken = req.cookies?.[COOKIE_NAME]; if (rawToken) { await this.authService.logout(rawToken); } clearRefreshCookie(res); return { success: true }; } @Post('logout-everywhere') @ApiOperation({ summary: 'Revoke all sessions' }) @HttpCode(200) @ApiBearerAuth() @UseGuards(JwtAuthGuard) async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) { await this.authService.logoutEverywhere(req.user.sub); clearRefreshCookie(res); return { success: true }; } @Get('profile') @ApiOperation({ summary: 'Get current user profile' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) async getProfile(@Request() req: any) { return this.authService.getProfile(req.user.sub); } @Patch('intro-seen') @ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) @AllowViewer() async markIntroSeen(@Request() req: any) { await this.authService.markIntroSeen(req.user.sub); return { success: true }; } @Post('switch-org') @ApiOperation({ summary: 'Switch active organization' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) @AllowViewer() async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto, @Res({ passthrough: true }) res: Response) { const ip = req.headers['x-forwarded-for'] || req.ip; const ua = req.headers['user-agent']; const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua); if (result.refreshToken) { setRefreshCookie(res, result.refreshToken); } const { refreshToken, ...response } = result; return response; } // ─── Activation Endpoints ───────────────────────────────────────── @Get('activate') @ApiOperation({ summary: 'Validate an activation token' }) async validateActivation(@Query('token') token: string) { if (!token) throw new BadRequestException('Token required'); return this.authService.validateInviteToken(token); } @Post('activate') @ApiOperation({ summary: 'Activate user account with password' }) @Throttle({ default: { limit: 5, ttl: 60000 } }) async activate( @Body() body: { token: string; password: string; fullName: string }, @Res({ passthrough: true }) res: Response, ) { if (!body.token || !body.password || !body.fullName) { throw new BadRequestException('Token, password, and fullName are required'); } if (body.password.length < 8) { throw new BadRequestException('Password must be at least 8 characters'); } const result = await this.authService.activateUser(body.token, body.password, body.fullName); if (result.refreshToken) { setRefreshCookie(res, result.refreshToken); } const { refreshToken, ...response } = result; return response; } @Post('resend-activation') @ApiOperation({ summary: 'Resend activation email' }) @Throttle({ default: { limit: 2, ttl: 60000 } }) async resendActivation(@Body() body: { email: string }) { // Stubbed — will be implemented when email service is ready return { success: true, message: 'If an account exists, a new activation link has been sent.' }; } // ─── Password Reset Flow ────────────────────────────────────────── @Post('forgot-password') @ApiOperation({ summary: 'Request a password reset email' }) @HttpCode(200) @Throttle({ default: { limit: 3, ttl: 60000 } }) async forgotPassword(@Body() body: { email: string }) { if (!body.email) throw new BadRequestException('Email is required'); await this.authService.requestPasswordReset(body.email); // Always return same message to prevent account enumeration return { message: 'If that email exists, a password reset link has been sent.' }; } @Post('reset-password') @ApiOperation({ summary: 'Reset password using a reset token' }) @HttpCode(200) @Throttle({ default: { limit: 5, ttl: 60000 } }) async resetPassword(@Body() body: { token: string; newPassword: string }) { if (!body.token || !body.newPassword) { throw new BadRequestException('Token and newPassword are required'); } if (body.newPassword.length < 8) { throw new BadRequestException('Password must be at least 8 characters'); } await this.authService.resetPassword(body.token, body.newPassword); return { message: 'Password updated successfully.' }; } @Patch('change-password') @ApiOperation({ summary: 'Change password (authenticated)' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) @AllowViewer() async changePassword( @Request() req: any, @Body() body: { currentPassword: string; newPassword: string }, ) { if (!body.currentPassword || !body.newPassword) { throw new BadRequestException('currentPassword and newPassword are required'); } if (body.newPassword.length < 8) { throw new BadRequestException('Password must be at least 8 characters'); } await this.authService.changePassword(req.user.sub, body.currentPassword, body.newPassword); return { message: 'Password changed successfully.' }; } }