mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { PrismaService } from '../../common/prisma.service';
|
|
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
|
import * as bcrypt from 'bcrypt';
|
|
import * as crypto from 'crypto';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(private prisma: PrismaService, private jwt: JwtService) {}
|
|
|
|
async register(dto: RegisterDto) {
|
|
const exists = await this.prisma.user.findFirst({
|
|
where: { OR: [{ email: dto.email }, { phone: dto.phone }] },
|
|
});
|
|
if (exists) throw new ConflictException('Email or phone already registered');
|
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
|
const user = await this.prisma.user.create({
|
|
data: {
|
|
fullName: dto.fullName,
|
|
email: dto.email,
|
|
phone: dto.phone,
|
|
passwordHash,
|
|
nationality: dto.nationality,
|
|
nationalId: dto.nationalId,
|
|
passportNumber: dto.passportNumber
|
|
},
|
|
});
|
|
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
|
|
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
|
|
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
|
|
await this.prisma.userPreferences.create({ data: { userId: user.id } });
|
|
await this.createAuditLog(user.id, 'USER_REGISTERED', 'User', user.id, null, { email: user.email });
|
|
return await this.signToken(user.id, user.email, user.role, passenger.id);
|
|
}
|
|
|
|
async login(dto: LoginDto) {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { email: dto.email },
|
|
include: { passenger: true, agent: true },
|
|
});
|
|
if (!user) throw new UnauthorizedException('Invalid credentials');
|
|
|
|
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
throw new UnauthorizedException(`Account locked until ${user.lockedUntil.toISOString()}`);
|
|
}
|
|
|
|
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
|
|
await this.prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
failedLoginAttempts: { increment: 1 },
|
|
lockedUntil: user.failedLoginAttempts >= 4 ? new Date(Date.now() + 15 * 60 * 1000) : null
|
|
}
|
|
});
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
|
|
await this.prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() }
|
|
});
|
|
|
|
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
|
|
|
|
// Ensure passenger exists and get its ID
|
|
let passengerId = user.passenger?.id;
|
|
if (!passengerId) {
|
|
// If passenger doesn't exist, create it
|
|
const passenger = await this.prisma.passenger.create({
|
|
data: { userId: user.id }
|
|
});
|
|
passengerId = passenger.id;
|
|
// Also create loyalty and wallet accounts
|
|
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
|
|
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
|
|
}
|
|
|
|
return await this.signToken(user.id, user.email, user.role, passengerId, user.agent?.id);
|
|
}
|
|
|
|
async requestOtp(dto: RequestOtpDto) {
|
|
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
|
await this.prisma.otpCode.create({
|
|
data: { email: dto.email, code, purpose: dto.purpose, expiresAt }
|
|
});
|
|
console.log(`[OTP] ${dto.email} - ${code} (${dto.purpose})`);
|
|
return { sent: true, expiresIn: 600 };
|
|
}
|
|
|
|
async verifyOtp(dto: VerifyOtpDto) {
|
|
const otp = await this.prisma.otpCode.findFirst({
|
|
where: { email: dto.email, code: dto.code, purpose: dto.purpose, verified: false, expiresAt: { gt: new Date() } },
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
if (!otp) throw new BadRequestException('Invalid or expired OTP');
|
|
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { verified: true } });
|
|
return { verified: true };
|
|
}
|
|
|
|
async requestPasswordReset(dto: RequestPasswordResetDto) {
|
|
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
|
if (!user) return { sent: true };
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
|
await this.prisma.passwordResetToken.create({
|
|
data: { userId: user.id, token, expiresAt }
|
|
});
|
|
console.log(`[PASSWORD_RESET] ${dto.email} - ${token}`);
|
|
return { sent: true };
|
|
}
|
|
|
|
async resetPassword(dto: ResetPasswordDto) {
|
|
const resetToken = await this.prisma.passwordResetToken.findUnique({
|
|
where: { token: dto.token }
|
|
});
|
|
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
|
|
throw new BadRequestException('Invalid or expired reset token');
|
|
}
|
|
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
|
await this.prisma.user.update({
|
|
where: { id: resetToken.userId },
|
|
data: { passwordHash, failedLoginAttempts: 0, lockedUntil: null }
|
|
});
|
|
await this.prisma.passwordResetToken.update({
|
|
where: { id: resetToken.id },
|
|
data: { used: true }
|
|
});
|
|
await this.createAuditLog(resetToken.userId, 'PASSWORD_RESET', 'User', resetToken.userId, null, null);
|
|
return { reset: true };
|
|
}
|
|
|
|
async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
|
|
const { search, role, status, page = 1, pageSize = 10 } = filters;
|
|
const skip = (page - 1) * pageSize;
|
|
|
|
const where: any = {
|
|
role: { not: 'PASSENGER' }, // Exclude passenger accounts
|
|
};
|
|
|
|
if (search) {
|
|
where.OR = [
|
|
{ email: { contains: search, mode: 'insensitive' } },
|
|
{ fullName: { contains: search, mode: 'insensitive' } },
|
|
];
|
|
}
|
|
|
|
if (role) {
|
|
where.role = role;
|
|
}
|
|
|
|
// For status filtering, we check if user is active (no lock/block) or inactive
|
|
if (status === 'ACTIVE') {
|
|
where.AND = [
|
|
{ blockedUntil: { lte: new Date() } },
|
|
{ lockedUntil: { lte: new Date() } }
|
|
];
|
|
} else if (status === 'INACTIVE') {
|
|
where.OR = [
|
|
{ blockedUntil: { gt: new Date() } },
|
|
{ lockedUntil: { gt: new Date() } }
|
|
];
|
|
}
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.user.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
fullName: true,
|
|
role: true,
|
|
lastLoginAt: true,
|
|
createdAt: true,
|
|
blockedUntil: true,
|
|
lockedUntil: true,
|
|
},
|
|
skip,
|
|
take: pageSize,
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
this.prisma.user.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
items: items.map(user => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
fullName: user.fullName,
|
|
role: user.role,
|
|
lastLogin: user.lastLoginAt,
|
|
status: (!user.blockedUntil || user.blockedUntil <= new Date()) &&
|
|
(!user.lockedUntil || user.lockedUntil <= new Date())
|
|
? 'ACTIVE'
|
|
: 'INACTIVE',
|
|
})),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
};
|
|
}
|
|
|
|
async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) {
|
|
const exists = await this.prisma.user.findFirst({
|
|
where: { OR: [{ email: dto.email }] },
|
|
});
|
|
if (exists) throw new ConflictException('Email already registered');
|
|
|
|
const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10);
|
|
|
|
const user = await this.prisma.user.create({
|
|
data: {
|
|
email: dto.email,
|
|
fullName: dto.fullName,
|
|
role: dto.role as any,
|
|
phone: dto.email, // Use email as phone temporarily for unique constraint
|
|
passwordHash,
|
|
blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined,
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
fullName: true,
|
|
role: true,
|
|
lastLoginAt: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role });
|
|
|
|
return user;
|
|
}
|
|
|
|
async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) {
|
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
|
if (!user) throw new NotFoundException('User not found');
|
|
|
|
const updateData: any = {};
|
|
if (dto.fullName) updateData.fullName = dto.fullName;
|
|
if (dto.role) updateData.role = dto.role;
|
|
if (dto.status === 'ACTIVE') {
|
|
updateData.blockedUntil = null;
|
|
updateData.lockedUntil = null;
|
|
} else if (dto.status === 'INACTIVE') {
|
|
updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
const updated = await this.prisma.user.update({
|
|
where: { id },
|
|
data: updateData,
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
fullName: true,
|
|
role: true,
|
|
lastLoginAt: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData });
|
|
|
|
return updated;
|
|
}
|
|
|
|
async deleteUser(id: string) {
|
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
|
if (!user) throw new NotFoundException('User not found');
|
|
|
|
// Don't actually delete, just deactivate
|
|
await this.prisma.user.update({
|
|
where: { id },
|
|
data: { blockedUntil: new Date(), lockedUntil: new Date() },
|
|
});
|
|
|
|
await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null);
|
|
|
|
return { deleted: true };
|
|
}
|
|
|
|
async resetUserPassword(id: string, tempPassword: string) {
|
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
|
if (!user) throw new NotFoundException('User not found');
|
|
|
|
const passwordHash = await bcrypt.hash(tempPassword, 10);
|
|
await this.prisma.user.update({
|
|
where: { id },
|
|
data: {
|
|
passwordHash,
|
|
failedLoginAttempts: 0,
|
|
lockedUntil: null,
|
|
},
|
|
});
|
|
|
|
await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' });
|
|
|
|
return { reset: true, tempPassword };
|
|
}
|
|
|
|
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
|
// Get the full user data to include fullName
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { id: true, email: true, fullName: true, role: true }
|
|
});
|
|
|
|
const payload = { sub: userId, email, role, passengerId, agentId };
|
|
console.log('[AUTH] Creating JWT with payload:', payload);
|
|
|
|
const token = this.jwt.sign(payload);
|
|
console.log('[AUTH] JWT created, token length:', token.length);
|
|
|
|
const response = {
|
|
token,
|
|
user: {
|
|
id: userId,
|
|
email,
|
|
fullName: user?.fullName || email,
|
|
role,
|
|
passengerId,
|
|
agentId
|
|
}
|
|
};
|
|
console.log('[AUTH] Returning user object with passengerId:', response.user.passengerId);
|
|
return response;
|
|
}
|
|
|
|
private async createAuditLog(userId: string, action: string, entityType: string, entityId: string, oldData: any, newData: any) {
|
|
await this.prisma.auditLog.create({
|
|
data: { userId, action, entityType, entityId, oldData, newData }
|
|
});
|
|
}
|
|
|
|
async getProfile(userId: string) {
|
|
if (!userId) {
|
|
throw new UnauthorizedException('User ID not found in token');
|
|
}
|
|
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
include: {
|
|
passenger: {
|
|
include: {
|
|
loyalty: true,
|
|
wallet: true,
|
|
},
|
|
},
|
|
preferences: true,
|
|
devices: true,
|
|
},
|
|
});
|
|
|
|
if (!user) throw new UnauthorizedException('User not found');
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
phone: user.phone,
|
|
fullName: user.fullName,
|
|
role: user.role,
|
|
nationality: user.nationality,
|
|
nationalityCode: user.nationalityCode,
|
|
nationalId: user.nationalId,
|
|
passportNumber: user.passportNumber,
|
|
faydaVerified: user.faydaVerified,
|
|
faydaVerifiedAt: user.faydaVerifiedAt,
|
|
lastLoginAt: user.lastLoginAt,
|
|
createdAt: user.createdAt,
|
|
passenger: user.passenger ? {
|
|
id: user.passenger.id,
|
|
preferredLanguage: user.passenger.preferredLanguage,
|
|
loyalty: user.passenger.loyalty ? {
|
|
tier: user.passenger.loyalty.tier,
|
|
pointsBalance: user.passenger.loyalty.pointsBalance,
|
|
lifetimePoints: user.passenger.loyalty.lifetimePoints,
|
|
} : null,
|
|
wallet: user.passenger.wallet ? {
|
|
balanceMinor: user.passenger.wallet.balanceMinor,
|
|
currency: user.passenger.wallet.currency,
|
|
} : null,
|
|
} : null,
|
|
preferences: user.preferences,
|
|
devices: user.devices.map(device => ({
|
|
id: device.id,
|
|
platform: device.platform,
|
|
name: device.name,
|
|
pushToken: device.pushToken,
|
|
trusted: device.trusted,
|
|
lastSeenAt: device.lastSeenAt,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async logout(userId: string) {
|
|
// Invalidate all active sessions for this user
|
|
await this.prisma.session.deleteMany({
|
|
where: { userId }
|
|
});
|
|
|
|
// Log the logout action
|
|
await this.createAuditLog(userId, 'USER_LOGOUT', 'User', userId, null, null);
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Logged out successfully'
|
|
};
|
|
}
|
|
}
|