This commit is contained in:
@@ -1,5 +1,18 @@
|
|||||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Req, UseGuards } from '@nestjs/common';
|
import {
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
@@ -19,6 +32,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
|
|||||||
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||||
|
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||||
|
import { SignupStartDto } from './dto/signup-start.dto';
|
||||||
|
import { SignupVerifyDto } from './dto/signup-verify.dto';
|
||||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||||
@@ -41,6 +57,62 @@ export class AuthController {
|
|||||||
return this.authService.registerBasic(dto);
|
return this.authService.registerBasic(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('signup/start')
|
||||||
|
@Throttle(10, 60_000)
|
||||||
|
async signupStart(@Body() dto: SignupStartDto) {
|
||||||
|
return this.authService.signupStart(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('signup/verify')
|
||||||
|
@Throttle(20, 60_000)
|
||||||
|
async signupVerify(@Body() dto: SignupVerifyDto) {
|
||||||
|
return this.authService.signupVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('signup/complete')
|
||||||
|
@Throttle(10, 60_000)
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }]))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
signupToken: { type: 'string' },
|
||||||
|
musicRoles: { type: 'array', items: { type: 'string' } },
|
||||||
|
musicGenres: { type: 'array', items: { type: 'string' } },
|
||||||
|
experienceLevel: { type: 'string' },
|
||||||
|
favoriteInstruments: { type: 'array', items: { type: 'string' } },
|
||||||
|
favoriteMaqamat: { type: 'array', items: { type: 'string' } },
|
||||||
|
stageName: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
username: { type: 'string' },
|
||||||
|
bio: { type: 'string' },
|
||||||
|
location: { type: 'string' },
|
||||||
|
latitude: { type: 'number' },
|
||||||
|
longitude: { type: 'number' },
|
||||||
|
isPrivate: { type: 'boolean' },
|
||||||
|
avatarFile: { type: 'string', format: 'binary' },
|
||||||
|
},
|
||||||
|
required: ['signupToken', 'name'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async signupComplete(
|
||||||
|
@Body() dto: SignupCompleteDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files?: {
|
||||||
|
avatarFile?: Array<{
|
||||||
|
mimetype?: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
originalname?: string;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.authService.signupComplete(dto, files?.avatarFile?.[0]);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@Throttle(20, 60_000)
|
@Throttle(20, 60_000)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
PasswordResetCodeSchema,
|
PasswordResetCodeSchema,
|
||||||
} from './schemas/password-reset-code.schema';
|
} from './schemas/password-reset-code.schema';
|
||||||
import { RefreshToken, RefreshTokenSchema } from './schemas/refresh-token.schema';
|
import { RefreshToken, RefreshTokenSchema } from './schemas/refresh-token.schema';
|
||||||
|
import { SignupDraft, SignupDraftSchema } from './schemas/signup-draft.schema';
|
||||||
import {
|
import {
|
||||||
SuperAdminRefreshToken,
|
SuperAdminRefreshToken,
|
||||||
SuperAdminRefreshTokenSchema,
|
SuperAdminRefreshTokenSchema,
|
||||||
@@ -54,6 +55,10 @@ import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy';
|
|||||||
name: EmailVerificationCode.name,
|
name: EmailVerificationCode.name,
|
||||||
schema: EmailVerificationCodeSchema,
|
schema: EmailVerificationCodeSchema,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: SignupDraft.name,
|
||||||
|
schema: SignupDraftSchema,
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PasswordResetCodeDocument,
|
PasswordResetCodeDocument,
|
||||||
} from './schemas/password-reset-code.schema';
|
} from './schemas/password-reset-code.schema';
|
||||||
import { RefreshToken, RefreshTokenDocument } from './schemas/refresh-token.schema';
|
import { RefreshToken, RefreshTokenDocument } from './schemas/refresh-token.schema';
|
||||||
|
import { SignupDraft, SignupDraftDocument } from './schemas/signup-draft.schema';
|
||||||
import {
|
import {
|
||||||
SuperAdminRefreshToken,
|
SuperAdminRefreshToken,
|
||||||
SuperAdminRefreshTokenDocument,
|
SuperAdminRefreshTokenDocument,
|
||||||
@@ -26,6 +27,8 @@ export class AuthRepository {
|
|||||||
private readonly passwordResetCodeModel: Model<PasswordResetCodeDocument>,
|
private readonly passwordResetCodeModel: Model<PasswordResetCodeDocument>,
|
||||||
@InjectModel(EmailVerificationCode.name)
|
@InjectModel(EmailVerificationCode.name)
|
||||||
private readonly emailVerificationCodeModel: Model<EmailVerificationCodeDocument>,
|
private readonly emailVerificationCodeModel: Model<EmailVerificationCodeDocument>,
|
||||||
|
@InjectModel(SignupDraft.name)
|
||||||
|
private readonly signupDraftModel: Model<SignupDraftDocument>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
async createRefreshToken(userId: string, jti: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
||||||
@@ -280,4 +283,81 @@ export class AuthRepository {
|
|||||||
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
|
.updateMany({ userId: new Types.ObjectId(userId), used: false }, { used: true })
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertSignupDraft(params: {
|
||||||
|
email: string;
|
||||||
|
passwordHash: string;
|
||||||
|
codeHash: string;
|
||||||
|
codeExpiresAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
}): Promise<SignupDraftDocument> {
|
||||||
|
return this.signupDraftModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
email: params.email.toLowerCase(),
|
||||||
|
consumed: false,
|
||||||
|
expiresAt: { $gt: new Date() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email: params.email.toLowerCase(),
|
||||||
|
passwordHash: params.passwordHash,
|
||||||
|
codeHash: params.codeHash,
|
||||||
|
codeExpiresAt: params.codeExpiresAt,
|
||||||
|
expiresAt: params.expiresAt,
|
||||||
|
verifiedAt: null,
|
||||||
|
signupTokenHash: '',
|
||||||
|
signupTokenExpiresAt: null,
|
||||||
|
attempts: 0,
|
||||||
|
consumed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: true, upsert: true, setDefaultsOnInsert: true },
|
||||||
|
)
|
||||||
|
.select('+passwordHash +codeHash +signupTokenHash')
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSignupDraftWithSecrets(id: string): Promise<SignupDraftDocument | null> {
|
||||||
|
if (!Types.ObjectId.isValid(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.signupDraftModel
|
||||||
|
.findById(id)
|
||||||
|
.select('+passwordHash +codeHash +signupTokenHash')
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSignupDraftByTokenHash(signupTokenHash: string): Promise<SignupDraftDocument | null> {
|
||||||
|
return this.signupDraftModel
|
||||||
|
.findOne({
|
||||||
|
signupTokenHash,
|
||||||
|
consumed: false,
|
||||||
|
})
|
||||||
|
.select('+passwordHash +codeHash +signupTokenHash')
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementSignupDraftAttempts(id: string): Promise<void> {
|
||||||
|
await this.signupDraftModel.findByIdAndUpdate(id, { $inc: { attempts: 1 } }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markSignupDraftVerified(
|
||||||
|
id: string,
|
||||||
|
signupTokenHash: string,
|
||||||
|
signupTokenExpiresAt: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.signupDraftModel
|
||||||
|
.findByIdAndUpdate(id, {
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
signupTokenHash,
|
||||||
|
signupTokenExpiresAt,
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markSignupDraftConsumed(id: string): Promise<void> {
|
||||||
|
await this.signupDraftModel.findByIdAndUpdate(id, { consumed: true }).exec();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
@@ -185,3 +185,269 @@ describe('AuthService Google token login', () => {
|
|||||||
expect(authServiceSource).not.toContain('admin.auth().verifyIdToken');
|
expect(authServiceSource).not.toContain('admin.auth().verifyIdToken');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AuthService deferred signup flow', () => {
|
||||||
|
const createDeferredSignupService = (overrides?: {
|
||||||
|
usersService?: Record<string, jest.Mock>;
|
||||||
|
authRepository?: Record<string, jest.Mock>;
|
||||||
|
jwtService?: Record<string, jest.Mock>;
|
||||||
|
}) => {
|
||||||
|
const usersService = {
|
||||||
|
findByEmail: jest.fn().mockResolvedValue(null),
|
||||||
|
findByUsername: jest.fn().mockResolvedValue(null),
|
||||||
|
createWithOptionalAvatarFile: jest.fn(),
|
||||||
|
findByIdOrFail: jest.fn(),
|
||||||
|
rollbackSignupCreatedUser: jest.fn(),
|
||||||
|
...(overrides?.usersService ?? {}),
|
||||||
|
};
|
||||||
|
const authRepository = {
|
||||||
|
upsertSignupDraft: jest.fn().mockResolvedValue({ id: 'draft-1' }),
|
||||||
|
findSignupDraftWithSecrets: jest.fn(),
|
||||||
|
incrementSignupDraftAttempts: jest.fn().mockResolvedValue(undefined),
|
||||||
|
markSignupDraftVerified: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findSignupDraftByTokenHash: jest.fn(),
|
||||||
|
markSignupDraftConsumed: jest.fn().mockResolvedValue(undefined),
|
||||||
|
createRefreshToken: jest.fn().mockResolvedValue(undefined),
|
||||||
|
...(overrides?.authRepository ?? {}),
|
||||||
|
};
|
||||||
|
const jwtService = {
|
||||||
|
signAsync: jest.fn().mockResolvedValueOnce('access-token').mockResolvedValueOnce('refresh-token'),
|
||||||
|
...(overrides?.jwtService ?? {}),
|
||||||
|
};
|
||||||
|
const configService = {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
const values: Record<string, string | number> = {
|
||||||
|
nodeEnv: 'test',
|
||||||
|
'security.bcryptSaltRounds': 4,
|
||||||
|
'emailVerification.maxAttempts': 5,
|
||||||
|
'jwt.accessSecret': 'access-secret',
|
||||||
|
'jwt.accessExpiresIn': '15m',
|
||||||
|
'jwt.refreshSecret': 'refresh-secret',
|
||||||
|
'jwt.refreshExpiresIn': '30d',
|
||||||
|
'security.refreshTokenHashSecret': 'refresh-hash-secret',
|
||||||
|
};
|
||||||
|
return values[key];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const emailService = {
|
||||||
|
sendVerificationCode: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AuthService(
|
||||||
|
usersService as any,
|
||||||
|
authRepository as any,
|
||||||
|
jwtService as any,
|
||||||
|
configService as any,
|
||||||
|
emailService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, usersService, authRepository, jwtService, emailService };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('signup/start sends an OTP and does not create a user', async () => {
|
||||||
|
const { service, usersService, authRepository, emailService } = createDeferredSignupService();
|
||||||
|
|
||||||
|
const result = await service.signupStart({
|
||||||
|
email: 'New.User@Example.com',
|
||||||
|
password: 'StrongPass123!',
|
||||||
|
confirmPassword: 'StrongPass123!',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
message: 'Verification code sent',
|
||||||
|
email: 'new.user@example.com',
|
||||||
|
signupId: 'draft-1',
|
||||||
|
});
|
||||||
|
expect(result.debugCode).toMatch(/^\d{6}$/);
|
||||||
|
expect(usersService.findByEmail).toHaveBeenCalledWith('new.user@example.com');
|
||||||
|
expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled();
|
||||||
|
expect(authRepository.upsertSignupDraft).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ email: 'new.user@example.com' }),
|
||||||
|
);
|
||||||
|
expect(emailService.sendVerificationCode).toHaveBeenCalledWith(
|
||||||
|
'new.user@example.com',
|
||||||
|
expect.stringMatching(/^\d{6}$/),
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signup/start fails if the email is already used', async () => {
|
||||||
|
const { service, authRepository } = createDeferredSignupService({
|
||||||
|
usersService: { findByEmail: jest.fn().mockResolvedValue({ id: 'existing-user' }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.signupStart({
|
||||||
|
email: 'used@example.com',
|
||||||
|
password: 'StrongPass123!',
|
||||||
|
confirmPassword: 'StrongPass123!',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(authRepository.upsertSignupDraft).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signup/start fails if password confirmation does not match', async () => {
|
||||||
|
const { service, authRepository } = createDeferredSignupService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.signupStart({
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'StrongPass123!',
|
||||||
|
confirmPassword: 'DifferentPass123!',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(authRepository.upsertSignupDraft).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signup/verify returns a signup token without auth tokens', async () => {
|
||||||
|
const codeHash = await hashValue('123456', 4);
|
||||||
|
const { service, authRepository, usersService } = createDeferredSignupService();
|
||||||
|
authRepository.findSignupDraftWithSecrets.mockResolvedValue({
|
||||||
|
id: 'draft-1',
|
||||||
|
email: 'new@example.com',
|
||||||
|
codeHash,
|
||||||
|
codeExpiresAt: new Date(Date.now() + 60_000),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
attempts: 0,
|
||||||
|
consumed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.signupVerify({
|
||||||
|
signupId: 'draft-1',
|
||||||
|
email: 'new@example.com',
|
||||||
|
code: '123456',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.message).toBe('Email verified');
|
||||||
|
expect(result.signupToken).toEqual(expect.any(String));
|
||||||
|
expect((result as any).accessToken).toBeUndefined();
|
||||||
|
expect((result as any).refreshToken).toBeUndefined();
|
||||||
|
expect(authRepository.markSignupDraftVerified).toHaveBeenCalledWith(
|
||||||
|
'draft-1',
|
||||||
|
expect.stringMatching(/^sha256:/),
|
||||||
|
expect.any(Date),
|
||||||
|
);
|
||||||
|
expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signup/verify fails with a wrong code', async () => {
|
||||||
|
const codeHash = await hashValue('123456', 4);
|
||||||
|
const { service, authRepository } = createDeferredSignupService();
|
||||||
|
authRepository.findSignupDraftWithSecrets.mockResolvedValue({
|
||||||
|
id: 'draft-1',
|
||||||
|
email: 'new@example.com',
|
||||||
|
codeHash,
|
||||||
|
codeExpiresAt: new Date(Date.now() + 60_000),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
attempts: 0,
|
||||||
|
consumed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.signupVerify({
|
||||||
|
signupId: 'draft-1',
|
||||||
|
email: 'new@example.com',
|
||||||
|
code: '654321',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(authRepository.incrementSignupDraftAttempts).toHaveBeenCalledWith('draft-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signup/complete creates the user and returns auth tokens', async () => {
|
||||||
|
const createdUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'new_user',
|
||||||
|
role: 'user',
|
||||||
|
avatar: '',
|
||||||
|
};
|
||||||
|
const safeUser = {
|
||||||
|
toObject: () => ({ id: 'user-1', email: 'new@example.com', username: 'new_user' }),
|
||||||
|
};
|
||||||
|
const { service, usersService, authRepository } = createDeferredSignupService();
|
||||||
|
authRepository.findSignupDraftByTokenHash.mockResolvedValue({
|
||||||
|
id: 'draft-1',
|
||||||
|
email: 'new@example.com',
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
signupTokenExpiresAt: new Date(Date.now() + 60_000),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
consumed: false,
|
||||||
|
});
|
||||||
|
usersService.createWithOptionalAvatarFile.mockResolvedValue(createdUser);
|
||||||
|
usersService.findByIdOrFail.mockResolvedValue(safeUser);
|
||||||
|
|
||||||
|
const result = await service.signupComplete({
|
||||||
|
signupToken: 'temporary-token',
|
||||||
|
name: 'New User',
|
||||||
|
username: 'new_user',
|
||||||
|
musicRoles: [],
|
||||||
|
musicGenres: ['Tarab'],
|
||||||
|
favoriteInstruments: ['Oud'],
|
||||||
|
favoriteMaqamat: ['Bayati'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usersService.createWithOptionalAvatarFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'hashed-password',
|
||||||
|
isEmailVerified: true,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
message: 'Account created successfully',
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
user: { id: 'user-1' },
|
||||||
|
});
|
||||||
|
expect(authRepository.markSignupDraftConsumed).toHaveBeenCalledWith('draft-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signup/complete fails with an invalid signup token', async () => {
|
||||||
|
const { service, usersService } = createDeferredSignupService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.signupComplete({
|
||||||
|
signupToken: 'bad-token',
|
||||||
|
name: 'New User',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(usersService.createWithOptionalAvatarFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the created user if complete fails after user creation', async () => {
|
||||||
|
const createdUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'new_user',
|
||||||
|
role: 'user',
|
||||||
|
avatar: 'uploads/users/avatar/avatar.webp',
|
||||||
|
};
|
||||||
|
const { service, usersService, authRepository } = createDeferredSignupService({
|
||||||
|
authRepository: {
|
||||||
|
markSignupDraftConsumed: jest.fn().mockRejectedValue(new Error('draft update failed')),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authRepository.findSignupDraftByTokenHash.mockResolvedValue({
|
||||||
|
id: 'draft-1',
|
||||||
|
email: 'new@example.com',
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
signupTokenExpiresAt: new Date(Date.now() + 60_000),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
consumed: false,
|
||||||
|
});
|
||||||
|
usersService.createWithOptionalAvatarFile.mockResolvedValue(createdUser);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.signupComplete({
|
||||||
|
signupToken: 'temporary-token',
|
||||||
|
name: 'New User',
|
||||||
|
username: 'new_user',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('draft update failed');
|
||||||
|
expect(usersService.rollbackSignupCreatedUser).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
'uploads/users/avatar/avatar.webp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
GoneException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
@@ -26,6 +30,9 @@ import { ResetPasswordDto } from './dto/reset-password.dto';
|
|||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
import { SendEmailVerificationDto } from './dto/send-email-verification.dto';
|
||||||
|
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||||
|
import { SignupStartDto } from './dto/signup-start.dto';
|
||||||
|
import { SignupVerifyDto } from './dto/signup-verify.dto';
|
||||||
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
import { SuperAdminLoginDto } from './dto/super-admin-login.dto';
|
||||||
import { VerifyEmailDto } from './dto/verify-email.dto';
|
import { VerifyEmailDto } from './dto/verify-email.dto';
|
||||||
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
|
||||||
@@ -33,6 +40,13 @@ import { AuthRepository } from './auth.repository';
|
|||||||
import { AuthResult, TokenPair } from './types/token-pair.type';
|
import { AuthResult, TokenPair } from './types/token-pair.type';
|
||||||
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||||
|
|
||||||
|
type UploadedImageFile = {
|
||||||
|
mimetype?: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
originalname?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly googleOAuthClient = new OAuth2Client();
|
private readonly googleOAuthClient = new OAuth2Client();
|
||||||
@@ -94,6 +108,123 @@ export class AuthService {
|
|||||||
: { message, email: user.email };
|
: { message, email: user.email };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async signupStart(
|
||||||
|
dto: SignupStartDto,
|
||||||
|
): Promise<{ message: string; email: string; signupId: string; debugCode?: string }> {
|
||||||
|
if (dto.password !== dto.confirmPassword) {
|
||||||
|
throw new BadRequestException('Password confirmation does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
|
await this.assertEmailNotUsed(normalizedEmail);
|
||||||
|
|
||||||
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
const [passwordHash, code] = await Promise.all([
|
||||||
|
hashValue(dto.password, saltRounds),
|
||||||
|
Promise.resolve(this.generateResetCode()),
|
||||||
|
]);
|
||||||
|
const codeHash = await hashValue(code, saltRounds);
|
||||||
|
const ttlMinutes = this.getSignupDraftTtlMinutes();
|
||||||
|
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
const draft = await this.authRepository.upsertSignupDraft({
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash,
|
||||||
|
codeHash,
|
||||||
|
codeExpiresAt: expiresAt,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.emailService.sendVerificationCode(normalizedEmail, code, ttlMinutes);
|
||||||
|
this.logger.log(`Signup draft verification code generated for ${normalizedEmail}`);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
message: 'Verification code sent',
|
||||||
|
email: normalizedEmail,
|
||||||
|
signupId: draft.id,
|
||||||
|
};
|
||||||
|
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
|
||||||
|
return nodeEnv !== 'production' ? { ...response, debugCode: code } : response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async signupVerify(dto: SignupVerifyDto): Promise<{ message: string; signupToken: string }> {
|
||||||
|
const normalizedEmail = dto.email.toLowerCase();
|
||||||
|
const draft = await this.authRepository.findSignupDraftWithSecrets(dto.signupId);
|
||||||
|
this.assertUsableSignupDraft(draft, normalizedEmail);
|
||||||
|
|
||||||
|
const maxAttempts = this.getSignupMaxAttempts();
|
||||||
|
if (draft.attempts >= maxAttempts) {
|
||||||
|
throw new HttpException('Too many verification attempts', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await compareHash(dto.code, draft.codeHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
await this.authRepository.incrementSignupDraftAttempts(draft.id);
|
||||||
|
if (draft.attempts + 1 >= maxAttempts) {
|
||||||
|
throw new HttpException('Too many verification attempts', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
throw new BadRequestException('Invalid verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const signupToken = randomBytes(32).toString('base64url');
|
||||||
|
const signupTokenHash = hashHighEntropyValue(signupToken, this.getSignupTokenHashSecret());
|
||||||
|
const signupTokenExpiresAt = new Date(Date.now() + this.getSignupTokenTtlMinutes() * 60 * 1000);
|
||||||
|
await this.authRepository.markSignupDraftVerified(draft.id, signupTokenHash, signupTokenExpiresAt);
|
||||||
|
|
||||||
|
return { message: 'Email verified', signupToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
async signupComplete(dto: SignupCompleteDto, avatarFile?: UploadedImageFile): Promise<AuthResult & { message: string }> {
|
||||||
|
const signupTokenHash = hashHighEntropyValue(dto.signupToken, this.getSignupTokenHashSecret());
|
||||||
|
const draft = await this.authRepository.findSignupDraftByTokenHash(signupTokenHash);
|
||||||
|
this.assertCompletableSignupDraft(draft);
|
||||||
|
|
||||||
|
await this.assertEmailNotUsed(draft.email);
|
||||||
|
|
||||||
|
const username = dto.username?.trim()
|
||||||
|
? dto.username.trim()
|
||||||
|
: await this.generateUniqueUsernameFromEmail(draft.email);
|
||||||
|
const createdUser = await this.usersService.createWithOptionalAvatarFile(
|
||||||
|
{
|
||||||
|
name: dto.name,
|
||||||
|
stageName: dto.stageName,
|
||||||
|
username,
|
||||||
|
email: draft.email,
|
||||||
|
password: draft.passwordHash,
|
||||||
|
bio: dto.bio,
|
||||||
|
location: dto.location,
|
||||||
|
latitude: dto.latitude,
|
||||||
|
longitude: dto.longitude,
|
||||||
|
isPrivate: dto.isPrivate,
|
||||||
|
musicRoles: dto.musicRoles,
|
||||||
|
musicGenres: dto.musicGenres,
|
||||||
|
experienceLevel: dto.experienceLevel,
|
||||||
|
favoriteInstruments: dto.favoriteInstruments,
|
||||||
|
favoriteMaqamat: dto.favoriteMaqamat,
|
||||||
|
isEmailVerified: true,
|
||||||
|
},
|
||||||
|
avatarFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await this.generateAndStoreTokenPair(
|
||||||
|
createdUser.id,
|
||||||
|
createdUser.username,
|
||||||
|
createdUser.role ?? 'user',
|
||||||
|
);
|
||||||
|
await this.authRepository.markSignupDraftConsumed(draft.id);
|
||||||
|
const safeUser = await this.usersService.findByIdOrFail(createdUser.id);
|
||||||
|
return {
|
||||||
|
message: 'Account created successfully',
|
||||||
|
...tokens,
|
||||||
|
user: safeUser.toObject() as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await this.usersService.rollbackSignupCreatedUser(createdUser.id, createdUser.avatar);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async login(dto: LoginDto): Promise<AuthResult> {
|
async login(dto: LoginDto): Promise<AuthResult> {
|
||||||
const user = await this.usersService.findByEmailWithPassword(dto.email);
|
const user = await this.usersService.findByEmailWithPassword(dto.email);
|
||||||
if (!user || !user.password) {
|
if (!user || !user.password) {
|
||||||
@@ -697,6 +828,62 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSignupTokenHashSecret(): string {
|
||||||
|
return this.getRefreshTokenHashSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSignupDraftTtlMinutes(): number {
|
||||||
|
const configured = this.configService.get<number>('signup.draftTtlMinutes', { infer: true });
|
||||||
|
return this.clampMinutes(configured ?? 20, 15, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSignupTokenTtlMinutes(): number {
|
||||||
|
const configured = this.configService.get<number>('signup.tokenTtlMinutes', { infer: true });
|
||||||
|
return this.clampMinutes(configured ?? 15, 5, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSignupMaxAttempts(): number {
|
||||||
|
return this.configService.get<number>('emailVerification.maxAttempts', { infer: true }) ?? 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampMinutes(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertEmailNotUsed(email: string): Promise<void> {
|
||||||
|
const existingUser = await this.usersService.findByEmail(email);
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('Email already used');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertUsableSignupDraft(
|
||||||
|
draft: Awaited<ReturnType<AuthRepository['findSignupDraftWithSecrets']>>,
|
||||||
|
email: string,
|
||||||
|
): asserts draft is NonNullable<typeof draft> {
|
||||||
|
if (!draft || draft.consumed || draft.email !== email) {
|
||||||
|
throw new BadRequestException('Invalid signup draft');
|
||||||
|
}
|
||||||
|
if (draft.expiresAt <= new Date() || draft.codeExpiresAt <= new Date()) {
|
||||||
|
throw new GoneException('Signup draft expired');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCompletableSignupDraft(
|
||||||
|
draft: Awaited<ReturnType<AuthRepository['findSignupDraftByTokenHash']>>,
|
||||||
|
): asserts draft is NonNullable<typeof draft> {
|
||||||
|
if (!draft || !draft.verifiedAt || !draft.signupTokenExpiresAt) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired signup token');
|
||||||
|
}
|
||||||
|
if (draft.expiresAt <= new Date() || draft.signupTokenExpiresAt <= new Date()) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired signup token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getSuperAdminPermissions(): string[] {
|
private getSuperAdminPermissions(): string[] {
|
||||||
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
|
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ExperienceLevel } from '../../../common/enums/experience-level.enum';
|
||||||
|
import { MusicRole } from '../../../common/enums/music-role.enum';
|
||||||
|
import { toStringArray } from '../../../common/utils/array-transform.util';
|
||||||
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
|
export class SignupCompleteDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
signupToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: MusicRole, isArray: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@IsEnum(MusicRole, { each: true })
|
||||||
|
musicRoles?: MusicRole[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], example: ['Tarab', 'Pop'] })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
musicGenres?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: ExperienceLevel, example: ExperienceLevel.BEGINNER })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ExperienceLevel)
|
||||||
|
experienceLevel?: ExperienceLevel;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], example: ['Oud', 'Piano'] })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
favoriteInstruments?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], example: ['Bayati', 'Rast'] })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toStringArray)
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
favoriteMaqamat?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'Artist One' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 80)
|
||||||
|
stageName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'John Doe' })
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'john_doe' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 30)
|
||||||
|
@Matches(/^[a-zA-Z0-9_.]+$/, { message: 'username can contain letters, numbers, _ and .' })
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, maxLength: 160 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 160)
|
||||||
|
bio?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'Riyadh, Saudi Arabia' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 120)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 24.7136, minimum: -90, maximum: 90 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 46.6753, minimum: -180, maximum: 180 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isPrivate?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupStartDto {
|
||||||
|
@ApiProperty({ example: 'john@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8 })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minLength: 8 })
|
||||||
|
@IsString()
|
||||||
|
@Length(8, 64)
|
||||||
|
confirmPassword!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupVerifyDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
signupId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'john@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { HydratedDocument } from 'mongoose';
|
||||||
|
|
||||||
|
export type SignupDraftDocument = HydratedDocument<SignupDraft>;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
|
export class SignupDraft {
|
||||||
|
@Prop({ required: true, trim: true, lowercase: true, index: true })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, select: false })
|
||||||
|
passwordHash!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, select: false })
|
||||||
|
codeHash!: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
codeExpiresAt!: Date;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: null })
|
||||||
|
verifiedAt?: Date | null;
|
||||||
|
|
||||||
|
@Prop({ type: String, default: '', select: false })
|
||||||
|
signupTokenHash!: string;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: null })
|
||||||
|
signupTokenExpiresAt?: Date | null;
|
||||||
|
|
||||||
|
@Prop({ default: 0, min: 0 })
|
||||||
|
attempts!: number;
|
||||||
|
|
||||||
|
@Prop({ default: false, index: true })
|
||||||
|
consumed!: boolean;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
expiresAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SignupDraftSchema = SchemaFactory.createForClass(SignupDraft);
|
||||||
|
|
||||||
|
SignupDraftSchema.index({ email: 1, consumed: 1, expiresAt: -1 });
|
||||||
|
SignupDraftSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
@@ -102,6 +102,36 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createWithOptionalAvatarFile(
|
||||||
|
dto: CreateUserDto & { password: string; role?: UserRole },
|
||||||
|
avatarFile?: UploadedImageFile,
|
||||||
|
): Promise<UserDocument> {
|
||||||
|
let uploadedAvatarUrl = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (avatarFile) {
|
||||||
|
uploadedAvatarUrl = await this.saveAvatarFile(avatarFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.create({
|
||||||
|
...dto,
|
||||||
|
...(uploadedAvatarUrl ? { avatar: uploadedAvatarUrl } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (uploadedAvatarUrl) {
|
||||||
|
await this.deleteManagedUpload(uploadedAvatarUrl);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollbackSignupCreatedUser(userId: string, avatarUrl?: string): Promise<void> {
|
||||||
|
await this.usersRepository.deleteById(userId);
|
||||||
|
if (avatarUrl) {
|
||||||
|
await this.deleteManagedUpload(avatarUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createAdminBySuperAdmin(
|
async createAdminBySuperAdmin(
|
||||||
superAdminIdentifier: string,
|
superAdminIdentifier: string,
|
||||||
dto: CreateAdminDto,
|
dto: CreateAdminDto,
|
||||||
|
|||||||
Reference in New Issue
Block a user