Add passwordless OTP login and SMS password reset.

Expose login-otp and reset-password so dashboards can finish forgot-password and one-time SMS sign-in, and sync website API docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:10:06 +03:30
co-authored by Cursor
parent f4295e780c
commit 08f901306c
11 changed files with 314 additions and 41 deletions
+16
View File
@@ -12,8 +12,10 @@ import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { LoginOtpDto } from './dto/login-otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { SendOtpDto } from './dto/send-otp.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
@@ -39,6 +41,20 @@ export class AuthController {
return this.authService.login(dto);
}
@Post('login-otp')
loginWithOtp(@Body() dto: LoginOtpDto) {
return this.authService.loginWithOtp(dto.cellNumber, dto.code);
}
@Post('reset-password')
resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(
dto.cellNumber,
dto.code,
dto.newPassword,
);
}
@Post('refresh')
refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto.refreshToken);
+66 -35
View File
@@ -307,27 +307,75 @@ export class AuthService {
}
async verifyOtp(cellNumber: string, code: string) {
if (!this.sms.isEnabled()) {
const user = await this.prisma.user.findUnique({
where: { cellNumber },
const user = await this.consumeOtp(cellNumber, code);
const smsEnabled = this.sms.isEnabled();
if (!user.cellVerifiedAt) {
await this.prisma.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
}
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
return {
enabled: smsEnabled,
verified: true,
message: smsEnabled
? 'Cell number verified successfully'
: 'SMS verification is disabled — cell number marked as verified.',
};
}
if (!user.cellVerifiedAt) {
await this.prisma.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
}
async loginWithOtp(cellNumber: string, code: string) {
const user = await this.consumeOtp(cellNumber, code);
return {
enabled: false,
verified: true,
message: 'SMS verification is disabled — cell number marked as verified.',
};
await this.prisma.user.update({
where: { id: user.id },
data: {
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
lastLoginAt: new Date(),
},
});
const authUser = await this.getAuthUser(user.id);
const tokens = await this.issueTokens(authUser);
return {
message: 'Login successful',
user: this.serializeUser(authUser),
...tokens,
};
}
async resetPassword(cellNumber: string, code: string, newPassword: string) {
const user = await this.consumeOtp(cellNumber, code);
const passwordHash = await bcrypt.hash(newPassword, 10);
await this.prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
},
});
return {
message: 'Password reset successfully. You can now sign in with your new password.',
};
}
/** Validates OTP (or skips when SMS is disabled) and returns the active user. */
private async consumeOtp(cellNumber: string, code: string) {
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Cell number is not registered');
}
if (!this.sms.isEnabled()) {
return user;
}
const storedCode = await this.redis.getOtp(cellNumber);
@@ -335,25 +383,8 @@ export class AuthService {
throw new UnauthorizedException('Invalid or expired verification code');
}
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
await this.prisma.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
await this.redis.deleteOtp(cellNumber);
return {
enabled: true,
verified: true,
message: 'Cell number verified successfully',
};
return user;
}
private async getAuthUser(userId: bigint): Promise<AuthUser> {
+14
View File
@@ -0,0 +1,14 @@
import { IsString, Length, Matches } from 'class-validator';
export class LoginOtpDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string;
}
+18
View File
@@ -0,0 +1,18 @@
import { IsString, Length, Matches, MinLength } from 'class-validator';
export class ResetPasswordDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string;
@IsString()
@MinLength(8, { message: 'newPassword must be at least 8 characters' })
newPassword!: string;
}
+1 -1
View File
@@ -32,7 +32,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items
4. Auth: register/login → store tokens
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
If OpenAPI and this brief conflict, **OpenAPI wins**.
@@ -200,6 +200,55 @@
"url": "{{baseUrl}}/auth/login"
}
},
{
"name": "Login with OTP",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" const json = pm.response.json();",
" pm.collectionVariables.set('accessToken', json.accessToken);",
" pm.collectionVariables.set('refreshToken', json.refreshToken);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"cellNumber\": \"+98XXXXXXXXXX\",\n \"code\": \"123456\"\n}"
},
"url": "{{baseUrl}}/auth/login-otp"
}
},
{
"name": "Reset password (SMS OTP)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"cellNumber\": \"+98XXXXXXXXXX\",\n \"code\": \"123456\",\n \"newPassword\": \"newpassword123\"\n}"
},
"url": "{{baseUrl}}/auth/reset-password"
}
},
{
"name": "Me (current user)",
"request": {
+45
View File
@@ -527,6 +527,51 @@
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/login-otp": {
"post": {
"tags": ["Auth"],
"summary": "Passwordless login with SMS OTP",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "code"],
"properties": {
"cellNumber": { "type": "string" },
"code": { "type": "string", "minLength": 6, "maxLength": 6 }
}
}
}
}
},
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/reset-password": {
"post": {
"tags": ["Auth"],
"summary": "Reset password with SMS OTP",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "code", "newPassword"],
"properties": {
"cellNumber": { "type": "string" },
"code": { "type": "string", "minLength": 6, "maxLength": 6 },
"newPassword": { "type": "string", "minLength": 8 }
}
}
}
}
},
"responses": { "200": { "description": "{ message }" } }
}
},
"/auth/refresh": {
"post": {
"tags": ["Auth"],