Add password login, set-password, business images migration, and user serialization.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const scryptAsync = promisify(scrypt);
|
||||||
|
const KEY_LENGTH = 64;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;
|
||||||
|
return `scrypt:${salt}:${derived.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
stored: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const parts = stored.split(':');
|
||||||
|
if (parts.length !== 3 || parts[0] !== 'scrypt') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [, salt, hashHex] = parts;
|
||||||
|
if (!salt || !hashHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;
|
||||||
|
const expected = Buffer.from(hashHex, 'hex');
|
||||||
|
if (derived.length !== expected.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(derived, expected);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
|
|
||||||
|
export type PublicUser = {
|
||||||
|
id: string;
|
||||||
|
mobile: string;
|
||||||
|
fullName: string | null;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
hasPassword: boolean;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function serializeUser(user: UserEntity): PublicUser {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
mobile: user.mobile,
|
||||||
|
fullName: user.fullName,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
hasPassword: Boolean(user.passwordHash),
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateBusinessImages1789040000000 implements MigrationInterface {
|
||||||
|
name = 'CreateBusinessImages1789040000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "business_images" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"businessId" uuid NOT NULL,
|
||||||
|
"url" character varying(500) NOT NULL,
|
||||||
|
"originalName" character varying(255),
|
||||||
|
"sortOrder" integer NOT NULL DEFAULT 0,
|
||||||
|
"isCover" boolean NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_business_images_id" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_business_images_business"
|
||||||
|
FOREIGN KEY ("businessId") REFERENCES "businesses"("id") ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_business_images_businessId"
|
||||||
|
ON "business_images" ("businessId")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'DROP INDEX IF EXISTS "IDX_business_images_businessId"',
|
||||||
|
);
|
||||||
|
await queryRunner.query('DROP TABLE IF EXISTS "business_images"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserPasswordHash1789041000000 implements MigrationInterface {
|
||||||
|
name = 'AddUserPasswordHash1789041000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "users"
|
||||||
|
ADD COLUMN IF NOT EXISTS "passwordHash" character varying(255)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "users"
|
||||||
|
DROP COLUMN IF EXISTS "passwordHash"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export class UserEntity {
|
|||||||
@Column({ default: true })
|
@Column({ default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true, length: 255, select: false })
|
||||||
|
passwordHash!: string | null;
|
||||||
|
|
||||||
@OneToMany(() => BusinessEntity, (business) => business.owner)
|
@OneToMany(() => BusinessEntity, (business) => business.owner)
|
||||||
ownedBusinesses!: BusinessEntity[];
|
ownedBusinesses!: BusinessEntity[];
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthUser } from './auth.types';
|
import { AuthUser } from './auth.types';
|
||||||
import { CurrentUser } from './decorators/current-user.decorator';
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
|
import { LoginPasswordDto } from './dto/login-password.dto';
|
||||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||||
|
import { SetPasswordDto } from './dto/set-password.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
|
||||||
@@ -20,6 +22,20 @@ export class AuthController {
|
|||||||
return this.auth.verifyOtp(payload);
|
return this.auth.verifyOtp(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('login-password')
|
||||||
|
loginWithPassword(@Body() payload: LoginPasswordDto) {
|
||||||
|
return this.auth.loginWithPassword(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('set-password')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
setPassword(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() payload: SetPasswordDto,
|
||||||
|
) {
|
||||||
|
return this.auth.setPassword(user.id, payload);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
me(@CurrentUser() user: AuthUser) {
|
me(@CurrentUser() user: AuthUser) {
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ import {
|
|||||||
isValidIranMobile,
|
isValidIranMobile,
|
||||||
normalizeIranMobile,
|
normalizeIranMobile,
|
||||||
} from '../../common/utils/iran-mobile.util';
|
} from '../../common/utils/iran-mobile.util';
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
} from '../../common/utils/password.util';
|
||||||
|
import { serializeUser } from '../../common/utils/user-serialize.util';
|
||||||
import { AuthOtpEntity } from '../../entities/auth-otp.entity';
|
import { AuthOtpEntity } from '../../entities/auth-otp.entity';
|
||||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||||
import { JwtPayload } from './auth.types';
|
import { JwtPayload } from './auth.types';
|
||||||
|
import { LoginPasswordDto } from './dto/login-password.dto';
|
||||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||||
|
import { SetPasswordDto } from './dto/set-password.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
@@ -28,9 +35,9 @@ type RequestOtpResponse = {
|
|||||||
otpCode?: string;
|
otpCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VerifyOtpResponse = {
|
type AuthSessionResponse = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
user: Pick<UserEntity, 'id' | 'mobile' | 'fullName' | 'role' | 'isActive'>;
|
user: ReturnType<typeof serializeUser>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -113,7 +120,7 @@ export class AuthService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyOtp(dto: VerifyOtpDto): Promise<VerifyOtpResponse> {
|
async verifyOtp(dto: VerifyOtpDto): Promise<AuthSessionResponse> {
|
||||||
const mobile = this.normalizeMobile(dto.mobile);
|
const mobile = this.normalizeMobile(dto.mobile);
|
||||||
const code = String(dto.code ?? '').trim();
|
const code = String(dto.code ?? '').trim();
|
||||||
|
|
||||||
@@ -148,24 +155,94 @@ export class AuthService {
|
|||||||
await this.otps.save(otp);
|
await this.otps.save(otp);
|
||||||
|
|
||||||
const user = await this.findOrCreateCustomer(mobile);
|
const user = await this.findOrCreateCustomer(mobile);
|
||||||
const payload: JwtPayload = {
|
|
||||||
sub: user.id,
|
|
||||||
mobile: user.mobile,
|
|
||||||
role: user.role,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return this.issueSession(user);
|
||||||
accessToken: await this.jwt.signAsync(payload),
|
}
|
||||||
user: this.serializeUser(user),
|
|
||||||
};
|
async loginWithPassword(dto: LoginPasswordDto): Promise<AuthSessionResponse> {
|
||||||
|
const mobile = this.normalizeMobile(dto.mobile);
|
||||||
|
const user = await this.users
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.where('user.mobile = :mobile', { mobile })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new UnauthorizedException('Invalid mobile or password');
|
||||||
|
}
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Password is not set. Sign in with OTP first, then set a password in your profile.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(dto.password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedException('Invalid mobile or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.issueSession(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(userId: string, dto: SetPasswordDto) {
|
||||||
|
if (dto.password !== dto.confirmPassword) {
|
||||||
|
throw new BadRequestException('Password confirmation does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.users
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.where('user.id = :id', { id: userId })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.passwordHash) {
|
||||||
|
if (!dto.currentPassword) {
|
||||||
|
throw new BadRequestException('Current password is required');
|
||||||
|
}
|
||||||
|
const valid = await verifyPassword(dto.currentPassword, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedException('Current password is incorrect');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.passwordHash = await hashPassword(dto.password);
|
||||||
|
const saved = await this.users.save(user);
|
||||||
|
return serializeUser(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
async me(userId: string) {
|
async me(userId: string) {
|
||||||
const user = await this.users.findOne({ where: { id: userId } });
|
const user = await this.users
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.where('user.id = :id', { id: userId })
|
||||||
|
.getOne();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('User not found');
|
throw new UnauthorizedException('User not found');
|
||||||
}
|
}
|
||||||
return this.serializeUser(user);
|
return serializeUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueSession(user: UserEntity): Promise<AuthSessionResponse> {
|
||||||
|
const full = await this.users
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.where('user.id = :id', { id: user.id })
|
||||||
|
.getOne();
|
||||||
|
const profile = full ?? user;
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: profile.id,
|
||||||
|
mobile: profile.mobile,
|
||||||
|
role: profile.role,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
accessToken: await this.jwt.signAsync(payload),
|
||||||
|
user: serializeUser(profile),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrCreateCustomer(mobile: string): Promise<UserEntity> {
|
private async findOrCreateCustomer(mobile: string): Promise<UserEntity> {
|
||||||
@@ -193,16 +270,6 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeUser(user: UserEntity) {
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
mobile: user.mobile,
|
|
||||||
fullName: user.fullName,
|
|
||||||
role: user.role,
|
|
||||||
isActive: user.isActive,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeMobile(mobile: string): string {
|
private normalizeMobile(mobile: string): string {
|
||||||
const normalized = normalizeIranMobile(mobile);
|
const normalized = normalizeIranMobile(mobile);
|
||||||
if (!isValidIranMobile(normalized)) {
|
if (!isValidIranMobile(normalized)) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator';
|
||||||
|
|
||||||
|
export class LoginPasswordDto {
|
||||||
|
@ApiProperty({ example: '09122222222' })
|
||||||
|
@IsIranMobile()
|
||||||
|
mobile!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'mySecurePass123' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(72)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetPasswordDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Required when changing an existing password' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(72)
|
||||||
|
currentPassword?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'mySecurePass123', minLength: 8 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(72)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'mySecurePass123' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(72)
|
||||||
|
confirmPassword!: string;
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
import { PublicUser } from '../../common/utils/user-serialize.util';
|
||||||
|
import { UserRole } from '../../entities/user.entity';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
@@ -20,12 +21,12 @@ export class UsersController {
|
|||||||
constructor(private readonly service: UsersService) {}
|
constructor(private readonly service: UsersService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(): Promise<UserEntity[]> {
|
findAll(): Promise<PublicUser[]> {
|
||||||
return this.service.findAll();
|
return this.service.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string): Promise<UserEntity> {
|
findOne(@Param('id') id: string): Promise<PublicUser> {
|
||||||
return this.service.findOne(id);
|
return this.service.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export class UsersController {
|
|||||||
update(
|
update(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() payload: UpdateUserDto,
|
@Body() payload: UpdateUserDto,
|
||||||
): Promise<UserEntity> {
|
): Promise<PublicUser> {
|
||||||
return this.service.update(id, payload);
|
return this.service.update(id, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { PublicUser, serializeUser } from '../../common/utils/user-serialize.util';
|
||||||
import { UserEntity } from '../../entities/user.entity';
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
|
||||||
@@ -11,20 +12,36 @@ export class UsersService {
|
|||||||
private readonly repo: Repository<UserEntity>,
|
private readonly repo: Repository<UserEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(): Promise<UserEntity[]> {
|
async findAll(): Promise<PublicUser[]> {
|
||||||
return this.repo.find({ order: { createdAt: 'DESC' } });
|
const users = await this.repo
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.orderBy('user.createdAt', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
return users.map(serializeUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<UserEntity> {
|
async findOne(id: string): Promise<PublicUser> {
|
||||||
const user = await this.repo.findOne({ where: { id } });
|
const user = await this.repo
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.where('user.id = :id', { id })
|
||||||
|
.getOne();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
return user;
|
return serializeUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateUserDto): Promise<UserEntity> {
|
async update(id: string, dto: UpdateUserDto): Promise<PublicUser> {
|
||||||
const user = await this.findOne(id);
|
const user = await this.repo
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.where('user.id = :id', { id })
|
||||||
|
.getOne();
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.fullName !== undefined) {
|
if (dto.fullName !== undefined) {
|
||||||
user.fullName = dto.fullName;
|
user.fullName = dto.fullName;
|
||||||
@@ -36,6 +53,7 @@ export class UsersService {
|
|||||||
user.isActive = dto.isActive;
|
user.isActive = dto.isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.repo.save(user);
|
const saved = await this.repo.save(user);
|
||||||
|
return serializeUser(saved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user