From 08f901306cf2bd1c4aa70365c81cf68b4c26334b Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 5 Aug 2026 15:10:06 +0330 Subject: [PATCH] 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 --- docs/PROJECT_CONTEXT.md | 14 ++- docs/website-api/AI_PROMPT.md | 2 +- ...eshkee-Website-API.postman_collection.json | 49 +++++++++ docs/website-api/openapi.json | 45 ++++++++ src/auth/auth.controller.ts | 16 +++ src/auth/auth.service.ts | 101 ++++++++++++------ src/auth/dto/login-otp.dto.ts | 14 +++ src/auth/dto/reset-password.dto.ts | 18 ++++ src/website-docs/static/AI_PROMPT.md | 2 +- ...eshkee-Website-API.postman_collection.json | 49 +++++++++ src/website-docs/static/openapi.json | 45 ++++++++ 11 files changed, 314 insertions(+), 41 deletions(-) create mode 100644 src/auth/dto/login-otp.dto.ts create mode 100644 src/auth/dto/reset-password.dto.ts diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 5d45035..9b1fcdf 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 4, 2026 +> Last updated: August 5, 2026 ## What This Project Is @@ -238,9 +238,11 @@ All routes are prefixed with `/api/v1`. |--------|------|-------------| | POST | `/auth/register` | Customer registration by domain | | POST | `/auth/login` | Cell + password | +| POST | `/auth/login-otp` | Passwordless login with SMS OTP | +| POST | `/auth/reset-password` | Reset password with SMS OTP | | POST | `/auth/refresh` | Refresh token | | POST | `/auth/send-otp` | Send OTP (Redis-backed) | -| POST | `/auth/verify-otp` | Verify OTP | +| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) | | GET | `/tenants/:host` | Resolve business from domain | | GET | `/tenants/:host/store-specials` | Active store specials | | GET | `/tenants/:host/website/category-groups` | Homepage category rows | @@ -361,6 +363,10 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f - Registration resolves tenant by `domain` → creates/links user → assigns `customer` role - OTP stored in Redis (`otp:{cellNumber}`), 5-min TTL; disabled when `SMS_ENABLED=false` +- `POST /auth/send-otp` → SMS code; `POST /auth/verify-otp` marks `cellVerifiedAt` (no tokens) +- `POST /auth/login-otp` → passwordless login (consumes OTP, verifies cell, returns tokens) +- `POST /auth/reset-password` → forgot password (OTP + `newPassword`, verifies cell) +- Password login (`POST /auth/login`) rejects unverified cells when SMS is enabled - JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type` - SMS provider: Gama (`sms.igama.ir`) SendQuick via service shortcode (`SMS_GAMA_*`) - Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message }`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs. @@ -552,7 +558,7 @@ See `.env.example` for the full list. Key groups: ### Implemented -- Multi-tenant auth (register, login, OTP, profile) +- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile) - Super admin: users, businesses, domains, system business categories - Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images) - Business team management @@ -566,6 +572,7 @@ See `.env.example` for the full list. Key groups: - Category variations & technical forms - Tenant resolution by domain - RBAC with granular permissions +- Auth SMS OTP via Gama SendQuick (`send-otp`, `verify-otp`, `login-otp`, `reset-password`) - Partner SMS gateway (`POST /public/sms/send`) + Gama SendQuick integration ### Planned / partial @@ -575,7 +582,6 @@ See `.env.example` for the full list. Key groups: | Blogs | Yes | Yes | No | No model | | Portfolios | Yes | Yes | Partial (migrate-from-old) | Yes | | Customer dashboard | Partial | No | Register only | Yes | -| SMS provider | — | — | Stub | — | | Store checkout (cart, orders) | Yes | Yes | Yes | Yes | | Customer favorites | — | `favorites.*` seeded | No | No | diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 5b0ddeb..61056e9 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -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**. diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index b1f86c2..02ed6bb 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -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": { diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 87048ce..880bc22 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -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"], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 2e88bd7..d9fef17 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 789829d..d57e857 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { diff --git a/src/auth/dto/login-otp.dto.ts b/src/auth/dto/login-otp.dto.ts new file mode 100644 index 0000000..f188de9 --- /dev/null +++ b/src/auth/dto/login-otp.dto.ts @@ -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; +} diff --git a/src/auth/dto/reset-password.dto.ts b/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..a266636 --- /dev/null +++ b/src/auth/dto/reset-password.dto.ts @@ -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; +} diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 5b0ddeb..61056e9 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -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**. diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index b1f86c2..02ed6bb 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -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": { diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 87048ce..880bc22 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -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"],