All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Body,
|
|
HttpCode,
|
|
HttpStatus,
|
|
UseGuards,
|
|
Get,
|
|
Inject,
|
|
NotFoundException,
|
|
InternalServerErrorException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { AuthService } from '../auth/auth.service';
|
|
import {
|
|
LoginDto,
|
|
RegisterDto,
|
|
AuthResponseDto,
|
|
RefreshTokenDto,
|
|
ForgotPasswordDto,
|
|
ResetPasswordDto,
|
|
ContactFormDto,
|
|
} from '../dto/auth-login.dto';
|
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
|
import { Public } from '../decorators/public.decorator';
|
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
import { UserMapper } from '../mappers/user.mapper';
|
|
import { InvitationService } from '../services/invitation.service';
|
|
|
|
/**
|
|
* Authentication Controller
|
|
*
|
|
* Handles user authentication endpoints:
|
|
* - POST /auth/register - User registration
|
|
* - POST /auth/login - User login
|
|
* - POST /auth/refresh - Token refresh
|
|
* - POST /auth/logout - User logout (placeholder)
|
|
* - GET /auth/me - Get current user profile
|
|
*/
|
|
@ApiTags('Authentication')
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
private readonly logger = new Logger(AuthController.name);
|
|
|
|
constructor(
|
|
private readonly authService: AuthService,
|
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
|
private readonly invitationService: InvitationService,
|
|
@Inject(EMAIL_PORT) private readonly emailService: EmailPort
|
|
) {}
|
|
|
|
/**
|
|
* Register a new user
|
|
*
|
|
* Creates a new user account and returns access + refresh tokens.
|
|
*
|
|
* @param dto - Registration data (email, password, firstName, lastName, organizationId)
|
|
* @returns Access token, refresh token, and user info
|
|
*/
|
|
@Public()
|
|
@Post('register')
|
|
@HttpCode(HttpStatus.CREATED)
|
|
@ApiOperation({
|
|
summary: 'Register new user',
|
|
description: 'Create a new user account with email and password. Returns JWT tokens.',
|
|
})
|
|
@ApiResponse({
|
|
status: 201,
|
|
description: 'User successfully registered',
|
|
type: AuthResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 409,
|
|
description: 'User with this email already exists',
|
|
})
|
|
@ApiResponse({
|
|
status: 400,
|
|
description: 'Validation error (invalid email, weak password, etc.)',
|
|
})
|
|
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
|
|
// If invitation token is provided, verify and use it
|
|
let invitationOrganizationId: string | undefined;
|
|
let invitationRole: string | undefined;
|
|
|
|
if (dto.invitationToken) {
|
|
const invitation = await this.invitationService.verifyInvitation(dto.invitationToken);
|
|
|
|
// Verify email matches invitation
|
|
if (invitation.email.toLowerCase() !== dto.email.toLowerCase()) {
|
|
throw new NotFoundException('Invitation email does not match registration email');
|
|
}
|
|
|
|
invitationOrganizationId = invitation.organizationId;
|
|
invitationRole = invitation.role;
|
|
|
|
// Override firstName/lastName from invitation if not provided
|
|
dto.firstName = dto.firstName || invitation.firstName;
|
|
dto.lastName = dto.lastName || invitation.lastName;
|
|
}
|
|
|
|
const result = await this.authService.register(
|
|
dto.email,
|
|
dto.password,
|
|
dto.firstName,
|
|
dto.lastName,
|
|
invitationOrganizationId || dto.organizationId,
|
|
dto.organization,
|
|
invitationRole
|
|
);
|
|
|
|
// Mark invitation as used if provided
|
|
if (dto.invitationToken) {
|
|
await this.invitationService.markInvitationAsUsed(dto.invitationToken);
|
|
}
|
|
|
|
return {
|
|
accessToken: result.accessToken,
|
|
refreshToken: result.refreshToken,
|
|
user: result.user,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Login with email and password
|
|
*
|
|
* Authenticates a user and returns access + refresh tokens.
|
|
*
|
|
* @param dto - Login credentials (email, password)
|
|
* @returns Access token, refresh token, and user info
|
|
*/
|
|
@Public()
|
|
@Post('login')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'User login',
|
|
description: 'Authenticate with email and password. Returns JWT tokens.',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Login successful',
|
|
type: AuthResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Invalid credentials or inactive account',
|
|
})
|
|
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
|
|
const result = await this.authService.login(dto.email, dto.password);
|
|
|
|
return {
|
|
accessToken: result.accessToken,
|
|
refreshToken: result.refreshToken,
|
|
user: result.user,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refresh access token
|
|
*
|
|
* Obtains a new access token using a valid refresh token.
|
|
*
|
|
* @param dto - Refresh token
|
|
* @returns New access token
|
|
*/
|
|
@Public()
|
|
@Post('refresh')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Refresh access token',
|
|
description:
|
|
'Get a new access token using a valid refresh token. Refresh tokens are long-lived (7 days).',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Token refreshed successfully',
|
|
schema: {
|
|
properties: {
|
|
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Invalid or expired refresh token',
|
|
})
|
|
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
|
|
const result = await this.authService.refreshAccessToken(dto.refreshToken);
|
|
|
|
return { accessToken: result.accessToken };
|
|
}
|
|
|
|
/**
|
|
* Logout (placeholder)
|
|
*
|
|
* Currently a no-op endpoint. With JWT, logout is typically handled client-side
|
|
* by removing tokens. For more security, implement token blacklisting with Redis.
|
|
*
|
|
* @returns Success message
|
|
*/
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post('logout')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Logout',
|
|
description: 'Logout the current user. Currently handled client-side by removing tokens.',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'Logout successful',
|
|
schema: {
|
|
properties: {
|
|
message: { type: 'string', example: 'Logout successful' },
|
|
},
|
|
},
|
|
})
|
|
async logout(): Promise<{ message: string }> {
|
|
// TODO: Implement token blacklisting with Redis for more security
|
|
// For now, logout is handled client-side by removing tokens
|
|
return { message: 'Logout successful' };
|
|
}
|
|
|
|
/**
|
|
* Contact form — forwards message to contact@xpeditis.com
|
|
*/
|
|
@Public()
|
|
@Post('contact')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Contact form',
|
|
description: 'Send a contact message to the Xpeditis team.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Message sent successfully' })
|
|
async contact(@Body() dto: ContactFormDto): Promise<{ message: string }> {
|
|
const subjectLabels: Record<string, string> = {
|
|
demo: 'Demande de démonstration',
|
|
pricing: 'Questions sur les tarifs',
|
|
partnership: 'Partenariat',
|
|
support: 'Support technique',
|
|
press: 'Relations presse',
|
|
careers: 'Recrutement',
|
|
other: 'Autre',
|
|
};
|
|
|
|
const subjectLabel = subjectLabels[dto.subject] || dto.subject;
|
|
|
|
const html = `
|
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
<div style="background: #10183A; padding: 24px; border-radius: 8px 8px 0 0;">
|
|
<h1 style="color: #34CCCD; margin: 0; font-size: 20px;">Nouveau message de contact</h1>
|
|
</div>
|
|
<div style="background: #f9f9f9; padding: 24px; border: 1px solid #e0e0e0;">
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
|
|
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${dto.firstName} ${dto.lastName}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
|
|
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${dto.email}" style="color: #34CCCD;">${dto.email}</a></td>
|
|
</tr>
|
|
${dto.company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.company}</td></tr>` : ''}
|
|
${dto.phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.phone}</td></tr>` : ''}
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
|
|
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
|
|
</tr>
|
|
</table>
|
|
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
|
|
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
|
|
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${dto.message}</p>
|
|
</div>
|
|
</div>
|
|
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
|
|
<p style="color: #999; font-size: 12px; margin: 0;">Xpeditis — Formulaire de contact</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
await this.emailService.send({
|
|
to: 'contact@xpeditis.com',
|
|
replyTo: dto.email,
|
|
subject: `[Contact] ${subjectLabel} — ${dto.firstName} ${dto.lastName}`,
|
|
html,
|
|
});
|
|
} catch (error) {
|
|
this.logger.error(`Failed to send contact email: ${error}`);
|
|
throw new InternalServerErrorException(
|
|
"Erreur lors de l'envoi du message. Veuillez réessayer."
|
|
);
|
|
}
|
|
|
|
return { message: 'Message envoyé avec succès.' };
|
|
}
|
|
|
|
/**
|
|
* Forgot password — sends reset email
|
|
*/
|
|
@Public()
|
|
@Post('forgot-password')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Forgot password',
|
|
description: 'Send a password reset email. Always returns 200 to avoid user enumeration.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Reset email sent (if account exists)' })
|
|
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<{ message: string }> {
|
|
await this.authService.forgotPassword(dto.email);
|
|
return {
|
|
message: 'Si un compte existe avec cet email, vous recevrez un lien de réinitialisation.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset password using token from email
|
|
*/
|
|
@Public()
|
|
@Post('reset-password')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Reset password',
|
|
description: 'Reset user password using the token received by email.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
|
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
|
|
async resetPassword(@Body() dto: ResetPasswordDto): Promise<{ message: string }> {
|
|
await this.authService.resetPassword(dto.token, dto.newPassword);
|
|
return { message: 'Mot de passe réinitialisé avec succès.' };
|
|
}
|
|
|
|
/**
|
|
* Get current user profile
|
|
*
|
|
* Returns the profile of the currently authenticated user with complete details.
|
|
*
|
|
* @param user - Current user from JWT token
|
|
* @returns User profile with firstName, lastName, etc.
|
|
*/
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('me')
|
|
@ApiBearerAuth()
|
|
@ApiOperation({
|
|
summary: 'Get current user profile',
|
|
description: 'Returns the complete profile of the authenticated user.',
|
|
})
|
|
@ApiResponse({
|
|
status: 200,
|
|
description: 'User profile retrieved successfully',
|
|
schema: {
|
|
properties: {
|
|
id: { type: 'string', format: 'uuid' },
|
|
email: { type: 'string', format: 'email' },
|
|
firstName: { type: 'string' },
|
|
lastName: { type: 'string' },
|
|
role: { type: 'string', enum: ['ADMIN', 'MANAGER', 'USER', 'VIEWER'] },
|
|
organizationId: { type: 'string', format: 'uuid' },
|
|
isActive: { type: 'boolean' },
|
|
createdAt: { type: 'string', format: 'date-time' },
|
|
updatedAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - invalid or missing token',
|
|
})
|
|
async getProfile(@CurrentUser() user: UserPayload) {
|
|
// Fetch complete user details from database
|
|
const fullUser = await this.userRepository.findById(user.id);
|
|
|
|
if (!fullUser) {
|
|
throw new NotFoundException('User not found');
|
|
}
|
|
|
|
// Return complete user data with firstName and lastName
|
|
return UserMapper.toDto(fullUser);
|
|
}
|
|
}
|