Improve cross-site customer signup and login membership.

Same-password register joins silently; mismatched password explains the existing Meshkee account; login/OTP with domain attaches business membership.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 01:02:47 +03:30
co-authored by Cursor
parent 953b87b616
commit acc8f35682
4 changed files with 96 additions and 18 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ export class AuthController {
@Post('login-otp') @Post('login-otp')
loginWithOtp(@Body() dto: LoginOtpDto) { loginWithOtp(@Body() dto: LoginOtpDto) {
return this.authService.loginWithOtp(dto.cellNumber, dto.code); return this.authService.loginWithOtp(dto.cellNumber, dto.code, dto.domain);
} }
@Post('reset-password') @Post('reset-password')
+83 -15
View File
@@ -57,16 +57,41 @@ export class AuthService {
const existingUser = await this.prisma.user.findUnique({ const existingUser = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber }, where: { cellNumber: dto.cellNumber },
include: { include: {
businessCustomers: { where: { businessId: business.id } }, businessCustomers: {
include: { business: { select: { id: true, name: true, nameFa: true } } },
},
}, },
}); });
if (existingUser?.businessCustomers.length) { const alreadyOnThisSite = existingUser?.businessCustomers.some(
(item) => item.businessId === business.id,
);
if (alreadyOnThisSite) {
throw new ConflictException( throw new ConflictException(
'This cell number is already registered on this website', 'This cell number is already registered on this website',
); );
} }
if (existingUser) {
const passwordValid = await bcrypt.compare(
dto.password,
existingUser.passwordHash,
);
if (!passwordValid) {
const otherNames = existingUser.businessCustomers
.map((item) => item.business.nameFa?.trim() || item.business.name)
.filter(Boolean);
const siteList =
otherNames.length > 0 ? otherNames.join(', ') : 'another Meshkee website';
throw new ConflictException(
`CELL_EXISTS_OTHER_SITE:${siteList}`,
);
}
}
const user = await this.prisma.$transaction(async (tx) => { const user = await this.prisma.$transaction(async (tx) => {
const account = const account =
existingUser ?? existingUser ??
@@ -81,18 +106,6 @@ export class AuthService {
}, },
})); }));
if (existingUser) {
const passwordValid = await bcrypt.compare(
dto.password,
existingUser.passwordHash,
);
if (!passwordValid) {
throw new ConflictException(
'Cell number exists on another account. Use login or reset password.',
);
}
}
await tx.businessCustomer.create({ await tx.businessCustomer.create({
data: { data: {
businessId: business.id, businessId: business.id,
@@ -159,6 +172,10 @@ export class AuthService {
); );
} }
if (dto.domain?.trim()) {
await this.ensureCustomerMembershipForDomain(user.id, dto.domain.trim());
}
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { lastLoginAt: new Date() }, data: { lastLoginAt: new Date() },
@@ -340,9 +357,13 @@ export class AuthService {
}; };
} }
async loginWithOtp(cellNumber: string, code: string) { async loginWithOtp(cellNumber: string, code: string, domain?: string) {
const user = await this.consumeOtp(cellNumber, code); const user = await this.consumeOtp(cellNumber, code);
if (domain?.trim()) {
await this.ensureCustomerMembershipForDomain(user.id, domain.trim());
}
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
@@ -401,6 +422,53 @@ export class AuthService {
return user; return user;
} }
private async ensureCustomerMembershipForDomain(
userId: bigint,
domain: string,
) {
const business = await this.tenant.resolveBusinessByDomain(domain);
const existing = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId: business.id, userId },
},
});
if (!existing) {
await this.prisma.businessCustomer.create({
data: {
businessId: business.id,
userId,
},
});
}
const customerRole = await this.prisma.role.findUnique({
where: { slug: 'customer' },
});
if (!customerRole) {
return;
}
const hasCustomerRole = await this.prisma.userRole.findUnique({
where: {
userId_roleId: {
userId,
roleId: customerRole.id,
},
},
});
if (!hasCustomerRole) {
await this.prisma.userRole.create({
data: {
userId,
roleId: customerRole.id,
},
});
}
}
private async getAuthUser(userId: bigint): Promise<AuthUser> { private async getAuthUser(userId: bigint): Promise<AuthUser> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
+6 -1
View File
@@ -1,4 +1,4 @@
import { IsString, Length, Matches } from 'class-validator'; import { IsOptional, IsString, Length, Matches } from 'class-validator';
export class LoginOtpDto { export class LoginOtpDto {
@IsString() @IsString()
@@ -11,4 +11,9 @@ export class LoginOtpDto {
@Length(6, 6) @Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' }) @Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string; code!: string;
/** Website apex — when set, attaches customer membership for that tenant on successful OTP login. */
@IsOptional()
@IsString()
domain?: string;
} }
+6 -1
View File
@@ -1,4 +1,4 @@
import { IsString, Matches, MinLength } from 'class-validator'; import { IsOptional, IsString, Matches, MinLength } from 'class-validator';
export class LoginDto { export class LoginDto {
@IsString() @IsString()
@@ -10,4 +10,9 @@ export class LoginDto {
@IsString() @IsString()
@MinLength(8) @MinLength(8)
password!: string; password!: string;
/** Website apex — when set, attaches customer membership for that tenant on successful login. */
@IsOptional()
@IsString()
domain?: string;
} }