diff --git a/backend/.env.example b/backend/.env.example index 3bd8d62..1793393 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,6 +20,16 @@ JWT_REFRESH_EXPIRES_IN=7d REDIS_HOST=localhost REDIS_PORT=6379 +# OTP SMS — Kavenegar (verify/lookup template API) +# Create an approved OTP template in the Kavenegar panel and put its name here. +# The template must contain a single %token placeholder for the code. +KAVENEGAR_API_KEY= +KAVENEGAR_OTP_TEMPLATE= +# Without these, OTP codes are logged to the API console in development only. + +# Seed admin mobile (login is mobile-only) — used by `npm run seed`. +ADMIN_PHONE=09120000000 + # In-cluster Docker Registry (Kaniko push + app image pull — same URL) REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000 # REGISTRY_PULL_URL=registry.cloudhost-builds.svc.cluster.local:5000 diff --git a/backend/migrations/018_user_phone_and_verification.sql b/backend/migrations/018_user_phone_and_verification.sql new file mode 100644 index 0000000..41abcb7 --- /dev/null +++ b/backend/migrations/018_user_phone_and_verification.sql @@ -0,0 +1,33 @@ +-- Mobile-first auth: phone is the login identifier, email becomes an optional +-- contact field, plus a table of short-lived one-time SMS codes for verifying +-- a phone (registration/login completion and number changes). + +-- Email becomes optional (login no longer uses it). Postgres treats NULLs as +-- distinct, so the existing UNIQUE constraint keeps working for users without one. +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; + +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR; +ALTER TABLE users ADD COLUMN IF NOT EXISTS "phoneVerified" BOOLEAN NOT NULL DEFAULT FALSE; + +-- Unique per non-null phone (NULLs allowed for legacy email-only staff accounts). +CREATE UNIQUE INDEX IF NOT EXISTS users_phone_unique ON users (phone) WHERE phone IS NOT NULL; + +-- One-time SMS verification codes (hashed). +DO $$ BEGIN + CREATE TYPE verification_codes_purpose_enum AS ENUM ('login', 'change_phone'); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +CREATE TABLE IF NOT EXISTS verification_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose verification_codes_purpose_enum NOT NULL, + destination VARCHAR NOT NULL, + "codeHash" VARCHAR NOT NULL, + "expiresAt" TIMESTAMPTZ NOT NULL, + attempts INT NOT NULL DEFAULT 0, + "consumedAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS verification_codes_user_purpose_idx + ON verification_codes ("userId", purpose); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 4c9d679..bbe8056 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; +import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @ApiTags('Authentication') @@ -11,22 +12,37 @@ export class AuthController { constructor(private readonly authService: AuthService) {} @Post('register') - @ApiOperation({ summary: 'Register a new user' }) - @ApiResponse({ status: 201, description: 'User registered successfully' }) - @ApiResponse({ status: 409, description: 'Email already registered' }) + @ApiOperation({ summary: 'Register with a mobile number (sends a verification code)' }) + @ApiResponse({ status: 201, description: 'Account created; phone verification required' }) + @ApiResponse({ status: 409, description: 'Mobile number already registered' }) async register(@Body() registerDto: RegisterDto) { return this.authService.register(registerDto); } @Post('login') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Login with email and password' }) - @ApiResponse({ status: 200, description: 'Login successful' }) + @ApiOperation({ summary: 'Login with mobile and password' }) + @ApiResponse({ status: 200, description: 'Login successful (or verification required)' }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) async login(@Body() loginDto: LoginDto) { return this.authService.login(loginDto); } + @Post('otp/request') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send a one-time login code to a mobile number' }) + async requestOtp(@Body() dto: OtpRequestDto) { + return this.authService.requestOtp(dto.phone); + } + + @Post('otp/verify') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify a one-time code (registration completion or OTP login)' }) + @ApiResponse({ status: 200, description: 'Verified; login successful' }) + async verifyOtp(@Body() dto: OtpVerifyDto) { + return this.authService.verifyOtp(dto.phone, dto.code); + } + @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Refresh access token' }) diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c9c1df9..960abad 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,46 +1,78 @@ -import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common'; +import { + Injectable, + UnauthorizedException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { UsersService } from '../users/users.service'; +import { VerificationService } from '../users/verification.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; +import { User } from '../users/entities/user.entity'; +import { normalizeIranMobile } from '../common/phone.util'; + +/** Returned when an action needs phone verification before tokens are issued. */ +export interface VerificationRequired { + requiresVerification: true; + phone: string; // masked +} @Injectable() export class AuthService { constructor( private usersService: UsersService, + private verificationService: VerificationService, private jwtService: JwtService, private configService: ConfigService, ) {} - async register(registerDto: RegisterDto) { - const existingUser = await this.usersService.findByEmail(registerDto.email); - if (existingUser) { - throw new ConflictException('Email already registered'); + /** + * Create an account from a mobile number. Email is optional contact info only. + * The account starts unverified; a LOGIN OTP is sent and must be confirmed via + * `verifyOtp` before tokens are issued. + */ + async register(registerDto: RegisterDto): Promise { + const phone = normalizeIranMobile(registerDto.phone); + if (!phone) { + throw new BadRequestException('Invalid mobile number'); + } + if (await this.usersService.findByPhone(phone)) { + throw new ConflictException('Mobile number already registered'); + } + + const email = registerDto.email?.trim().toLowerCase() || null; + if (email && (await this.usersService.findByEmail(email))) { + throw new ConflictException('Email already in use'); } const hashedPassword = await bcrypt.hash(registerDto.password, 12); const user = await this.usersService.create({ - ...registerDto, + phone, + email, + firstName: registerDto.firstName, + lastName: registerDto.lastName, password: hashedPassword, + phoneVerified: false, }); - const tokens = await this.generateTokens(user.id, user.email, user.role); - return { - user: { - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - }, - ...tokens, - }; + const { destination } = await this.verificationService.issueLoginOtp(user); + return { requiresVerification: true, phone: destination }; } + /** + * Password login by mobile. If the phone isn't verified yet, an OTP is sent and + * verification is required to finish. + */ async login(loginDto: LoginDto) { - const user = await this.usersService.findByEmail(loginDto.email); + const phone = normalizeIranMobile(loginDto.phone); + if (!phone) { + throw new UnauthorizedException('Invalid credentials'); + } + + const user = await this.usersService.findByPhone(phone); if (!user) { throw new UnauthorizedException('Invalid credentials'); } @@ -54,17 +86,46 @@ export class AuthService { throw new UnauthorizedException('Account is deactivated'); } - const tokens = await this.generateTokens(user.id, user.email, user.role); - return { - user: { - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - }, - ...tokens, - }; + if (!user.phoneVerified) { + const { destination } = await this.verificationService.issueLoginOtp(user); + return { requiresVerification: true, phone: destination } as VerificationRequired; + } + + const tokens = await this.generateTokens(user); + return { user: this.publicUser(user), ...tokens }; + } + + /** + * Passwordless login: send a one-time code to a registered phone. Returns a + * generic response regardless of whether the phone exists (no enumeration). + */ + async requestOtp(rawPhone: string) { + const phone = normalizeIranMobile(rawPhone); + if (phone) { + const user = await this.usersService.findByPhone(phone); + if (user && user.isActive) { + await this.verificationService.issueLoginOtp(user); + } + } + return { sent: true }; + } + + /** Verify a LOGIN OTP (registration completion or passwordless login). */ + async verifyOtp(rawPhone: string, code: string) { + const phone = normalizeIranMobile(rawPhone); + if (!phone) { + throw new BadRequestException('Invalid mobile number'); + } + const user = await this.usersService.findByPhone(phone); + if (!user || !user.isActive) { + throw new UnauthorizedException('Invalid credentials'); + } + + await this.verificationService.verifyLoginOtp(user, code); + + const fresh = (await this.usersService.findById(user.id)) ?? user; + const tokens = await this.generateTokens(fresh); + return { user: this.publicUser(fresh), ...tokens }; } async refreshToken(refreshToken: string) { @@ -78,14 +139,31 @@ export class AuthService { throw new UnauthorizedException(); } - return this.generateTokens(user.id, user.email, user.role); + return this.generateTokens(user); } catch { throw new UnauthorizedException('Invalid refresh token'); } } - private async generateTokens(userId: string, email: string, role: string) { - const payload = { sub: userId, email, role }; + private publicUser(user: User) { + return { + id: user.id, + phone: user.phone, + email: user.email, + phoneVerified: user.phoneVerified, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + }; + } + + private async generateTokens(user: User) { + // `email` may be null; use phone as the human-readable identity claim. + const payload = { + sub: user.id, + email: user.email ?? user.phone, + role: user.role, + }; const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(payload), diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts index 5aa67b3..bc44a70 100644 --- a/backend/src/auth/dto/login.dto.ts +++ b/backend/src/auth/dto/login.dto.ts @@ -1,10 +1,10 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class LoginDto { - @ApiProperty({ example: 'john@example.com' }) - @IsEmail() - email: string; + @ApiProperty({ example: '09121234567' }) + @IsString() + phone: string; @ApiProperty({ example: 'SecureP@ss123' }) @IsString() diff --git a/backend/src/auth/dto/otp.dto.ts b/backend/src/auth/dto/otp.dto.ts new file mode 100644 index 0000000..59cb139 --- /dev/null +++ b/backend/src/auth/dto/otp.dto.ts @@ -0,0 +1,20 @@ +import { IsString, MinLength, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class OtpRequestDto { + @ApiProperty({ example: '09121234567' }) + @IsString() + phone: string; +} + +export class OtpVerifyDto { + @ApiProperty({ example: '09121234567' }) + @IsString() + phone: string; + + @ApiProperty({ example: '123456' }) + @IsString() + @MinLength(4) + @MaxLength(8) + code: string; +} diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts index 7039220..ac87e37 100644 --- a/backend/src/auth/dto/register.dto.ts +++ b/backend/src/auth/dto/register.dto.ts @@ -1,10 +1,16 @@ -import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { + IsEmail, + IsString, + MinLength, + MaxLength, + IsOptional, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class RegisterDto { - @ApiProperty({ example: 'john@example.com' }) - @IsEmail() - email: string; + @ApiProperty({ example: '09121234567' }) + @IsString() + phone: string; @ApiProperty({ example: 'SecureP@ss123' }) @IsString() @@ -23,4 +29,9 @@ export class RegisterDto { @MinLength(1) @MaxLength(50) lastName: string; + + @ApiPropertyOptional({ example: 'john@example.com' }) + @IsOptional() + @IsEmail() + email?: string; } diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index 53c5836..dd0092b 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -7,6 +7,14 @@ export enum UserRole { SALES = 'sales', } +/** What a one-time SMS code authorises. */ +export enum VerificationPurpose { + /** Verify phone ownership for registration completion and passwordless login. */ + LOGIN = 'login', + /** Verify a new phone number when changing it from the account page. */ + CHANGE_PHONE = 'change_phone', +} + export enum TicketDepartment { TECHNICAL = 'technical', SALES = 'sales', diff --git a/backend/src/common/phone.util.ts b/backend/src/common/phone.util.ts new file mode 100644 index 0000000..df85972 --- /dev/null +++ b/backend/src/common/phone.util.ts @@ -0,0 +1,53 @@ +/** + * Iranian mobile number helpers. + * + * Canonical storage form is E.164: `+989XXXXXXXXX` (13 chars). + * Inputs are accepted liberally (Persian/Arabic digits, spaces, common + * prefixes) and normalised to that single canonical form so the same number + * can never be stored twice under different spellings. + */ + +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; +const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'; + +function toLatinDigits(input: string): string { + return input.replace(/[۰-۹٠-٩]/g, (ch) => { + const p = PERSIAN_DIGITS.indexOf(ch); + if (p > -1) return String(p); + const a = ARABIC_DIGITS.indexOf(ch); + if (a > -1) return String(a); + return ch; + }); +} + +/** + * Normalise any reasonable Iranian mobile spelling to canonical `+989XXXXXXXXX`. + * Returns `null` when the input is not a valid Iranian mobile number. + */ +export function normalizeIranMobile(raw: string | null | undefined): string | null { + if (!raw) return null; + let s = toLatinDigits(String(raw)).replace(/[\s\-()]/g, ''); + + // Strip international/access prefixes down to the national significant number. + if (s.startsWith('+98')) s = s.slice(3); + else if (s.startsWith('0098')) s = s.slice(4); + else if (s.startsWith('98') && s.length === 12) s = s.slice(2); + else if (s.startsWith('0')) s = s.slice(1); + + // National significant number for Iranian mobiles is `9XXXXXXXXX` (10 digits). + if (!/^9\d{9}$/.test(s)) return null; + return `+98${s}`; +} + +export function isValidIranMobile(raw: string | null | undefined): boolean { + return normalizeIranMobile(raw) !== null; +} + +/** + * Kavenegar's `receptor` expects the local `09XXXXXXXXX` form rather than E.164. + */ +export function toLocalMobile(e164: string): string { + const normalized = normalizeIranMobile(e164); + if (!normalized) return e164; + return `0${normalized.slice(3)}`; +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 32b70d6..aa1f124 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -84,6 +84,12 @@ export default () => ({ port: parseInt(process.env.REDIS_PORT || '6379', 10), }, + // OTP SMS via Kavenegar (verify/lookup template API). + sms: { + kavenegarApiKey: process.env.KAVENEGAR_API_KEY || '', + kavenegarOtpTemplate: process.env.KAVENEGAR_OTP_TEMPLATE || '', + }, + registry: { /** In-cluster registry — Kaniko push and app image pull (same host). */ url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts new file mode 100644 index 0000000..507b819 --- /dev/null +++ b/backend/src/notifications/notifications.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; + +@Module({ + providers: [SmsService], + exports: [SmsService], +}) +export class NotificationsModule {} diff --git a/backend/src/notifications/sms.service.ts b/backend/src/notifications/sms.service.ts new file mode 100644 index 0000000..d4b5379 --- /dev/null +++ b/backend/src/notifications/sms.service.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { toLocalMobile } from '../common/phone.util'; + +/** + * SMS delivery via Kavenegar's Verify Lookup API (OTP templates). + * https://kavenegar.com/rest.html#sms-lookup + * + * When credentials are absent we fall back to logging the code in development + * so the flow stays testable; in production a missing config is a hard error. + */ +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor(private readonly config: ConfigService) {} + + private get apiKey(): string | undefined { + return this.config.get('sms.kavenegarApiKey') || undefined; + } + + private get template(): string | undefined { + return this.config.get('sms.kavenegarOtpTemplate') || undefined; + } + + isConfigured(): boolean { + return Boolean(this.apiKey && this.template); + } + + async sendOtp(phoneE164: string, code: string): Promise { + if (!this.isConfigured()) { + if (this.config.get('nodeEnv') !== 'production') { + this.logger.warn( + `[DEV] SMS not configured — OTP for ${phoneE164} is ${code}`, + ); + return; + } + throw new ServiceUnavailableException('SMS delivery is not configured'); + } + + const receptor = toLocalMobile(phoneE164); + const url = + `https://api.kavenegar.com/v1/${this.apiKey}/verify/lookup.json` + + `?receptor=${encodeURIComponent(receptor)}` + + `&token=${encodeURIComponent(code)}` + + `&template=${encodeURIComponent(this.template!)}`; + + try { + const res = await fetch(url, { method: 'GET' }); + const body: any = await res.json().catch(() => null); + const status = body?.return?.status; + if (!res.ok || status !== 200) { + this.logger.error( + `Kavenegar OTP send failed (${status ?? res.status}): ${body?.return?.message ?? ''}`, + ); + throw new ServiceUnavailableException('Failed to send SMS code'); + } + } catch (err) { + if (err instanceof ServiceUnavailableException) throw err; + this.logger.error(`Kavenegar request error: ${(err as Error).message}`); + throw new ServiceUnavailableException('Failed to send SMS code'); + } + } +} diff --git a/backend/src/seed.ts b/backend/src/seed.ts index 73d5a21..ad985bf 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -6,6 +6,7 @@ import { AppModule } from './app.module'; import { UsersService } from './users/users.service'; import { PlatformSetting } from './billing/entities/platform-setting.entity'; import { UserRole } from './common/enums'; +import { normalizeIranMobile } from './common/phone.util'; /** * Seed script — creates the initial super admin user. @@ -26,18 +27,29 @@ async function bootstrap() { const email = process.env.ADMIN_EMAIL || 'test@example.com'; const password = process.env.ADMIN_PASSWORD || 'Admin123!'; + // Login is mobile-only, so the admin needs a verified phone to sign in. + const phone = normalizeIranMobile(process.env.ADMIN_PHONE || '09120000000'); const existing = await usersService.findByEmail(email); if (existing) { console.log(`⚠️ Admin user already exists: ${email} (role: ${existing.role})`); - if (existing.role !== UserRole.ADMIN) { - await usersService.update(existing.id, { role: UserRole.ADMIN }); - console.log(`✅ Promoted ${email} to admin`); + const patch: any = {}; + if (existing.role !== UserRole.ADMIN) patch.role = UserRole.ADMIN; + // Ensure the admin has a verified phone for mobile login. + if (!existing.phone && phone) { + patch.phone = phone; + patch.phoneVerified = true; + } + if (Object.keys(patch).length) { + await usersService.update(existing.id, patch); + console.log(`✅ Updated admin ${email}: ${Object.keys(patch).join(', ')}`); } } else { const hashedPassword = await bcrypt.hash(password, 12); await usersService.create({ email, + phone, + phoneVerified: true, password: hashedPassword, firstName: 'Super', lastName: 'Admin', @@ -47,6 +59,7 @@ async function bootstrap() { } console.log(`\n📋 Admin credentials:`); + console.log(` Mobile: ${phone}`); console.log(` Email: ${email}`); console.log(` Password: ${password}`); diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index e34b78d..87164d0 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -14,8 +14,18 @@ export class User { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ unique: true }) - email: string; + // Phone is the login identifier (required at registration). Email is an + // optional contact field only — it is never used to authenticate. + // Both are unique; Postgres treats NULLs as distinct, so accounts missing + // either coexist freely. + @Column({ unique: true, nullable: true }) + phone: string | null; // canonical E.164, e.g. +989121234567 + + @Column({ unique: true, nullable: true }) + email: string | null; + + @Column({ default: false }) + phoneVerified: boolean; @Column() password: string; diff --git a/backend/src/users/entities/verification-code.entity.ts b/backend/src/users/entities/verification-code.entity.ts new file mode 100644 index 0000000..23ef380 --- /dev/null +++ b/backend/src/users/entities/verification-code.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { VerificationPurpose } from '../../common/enums'; +import { User } from './user.entity'; + +/** + * A short-lived one-time SMS code used to prove ownership of a phone number, + * for registration/login verification and for changing the bound number. + * The code itself is stored only as a hash; `destination` holds the phone + * (E.164) the code was sent to. + */ +@Entity('verification_codes') +@Index(['userId', 'purpose']) +export class VerificationCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column() + userId: string; + + @Column({ type: 'enum', enum: VerificationPurpose }) + purpose: VerificationPurpose; + + /** The phone (E.164) this code was sent to / authorises binding. */ + @Column() + destination: string; + + /** bcrypt hash of the numeric code — never store the plaintext OTP. */ + @Column() + codeHash: string; + + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @Column({ type: 'int', default: 0 }) + attempts: number; + + @Column({ type: 'timestamptz', nullable: true }) + consumedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 995cc51..e49cbe2 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -13,6 +13,7 @@ import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator'; import { UsersService } from './users.service'; +import { VerificationService } from './verification.service'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { UserRole } from '../common/enums'; @@ -44,6 +45,51 @@ class UpdateRoleDto { role: UserRole; } +class UpdateProfileDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + lastName?: string; + + // Optional contact email (not a login identifier). Empty string clears it. + @IsOptional() + @IsString() + @MaxLength(120) + email?: string; +} + +class ChangePasswordDto { + @IsString() + currentPassword: string; + + @IsString() + @MinLength(8) + @MaxLength(64) + newPassword: string; +} + +class RequestPhoneChangeDto { + @IsString() + phone: string; + + @IsString() + currentPassword: string; +} + +class ConfirmCodeDto { + @IsString() + @MinLength(4) + @MaxLength(8) + code: string; +} + class AdminSetPasswordDto { @IsString() @MinLength(8) @@ -56,7 +102,10 @@ class AdminSetPasswordDto { @Controller('users') @UseGuards(AuthGuard('jwt'), RolesGuard) export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly verificationService: VerificationService, + ) {} @Get('me') @ApiOperation({ summary: 'Get current user profile' }) @@ -69,6 +118,46 @@ export class UsersController { return null; } + @Patch('me') + @ApiOperation({ summary: 'Update own name' }) + async updateProfile(@Request() req: any, @Body() dto: UpdateProfileDto) { + const user = await this.usersService.updateProfile(req.user.id, dto); + const { password, ...result } = user; + return result; + } + + @Post('me/password') + @ApiOperation({ summary: 'Change own password (requires current password)' }) + async changePassword(@Request() req: any, @Body() dto: ChangePasswordDto) { + await this.usersService.changeOwnPassword( + req.user.id, + dto.currentPassword, + dto.newPassword, + ); + return { message: 'Password updated' }; + } + + @Post('me/phone/request') + @ApiOperation({ + summary: 'Start phone change — sends an OTP to the new number', + }) + async requestPhoneChange( + @Request() req: any, + @Body() dto: RequestPhoneChangeDto, + ) { + return this.verificationService.requestPhoneChange( + req.user.id, + dto.phone, + dto.currentPassword, + ); + } + + @Post('me/phone/confirm') + @ApiOperation({ summary: 'Confirm phone change with the OTP' }) + async confirmPhoneChange(@Request() req: any, @Body() dto: ConfirmCodeDto) { + return this.verificationService.confirmPhoneChange(req.user.id, dto.code); + } + @Get() @Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES) @ApiOperation({ summary: 'List all users with optional search (Staff/Admin)' }) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 9dfa3d2..bbcb202 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,13 +1,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersService } from './users.service'; +import { VerificationService } from './verification.service'; import { UsersController } from './users.controller'; import { User } from './entities/user.entity'; +import { VerificationCode } from './entities/verification-code.entity'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [ + TypeOrmModule.forFeature([User, VerificationCode]), + NotificationsModule, + ], controllers: [UsersController], - providers: [UsersService], - exports: [UsersService], + providers: [UsersService, VerificationService], + exports: [UsersService, VerificationService], }) export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 960d5e2..0ddc451 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -3,6 +3,8 @@ import { NotFoundException, ConflictException, ForbiddenException, + UnauthorizedException, + BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, ILike } from 'typeorm'; @@ -50,13 +52,32 @@ export class UsersService { } async findByEmail(email: string): Promise { - return this.usersRepository.findOne({ where: { email } }); + return this.usersRepository.findOne({ where: { email: email.toLowerCase() } }); + } + + async findByPhone(phone: string): Promise { + return this.usersRepository.findOne({ where: { phone } }); + } + + /** Login lookup: match against whichever identifier was supplied. */ + async findByEmailOrPhone(opts: { + email?: string; + phone?: string; + }): Promise { + if (opts.email) return this.findByEmail(opts.email); + if (opts.phone) return this.findByPhone(opts.phone); + return null; } async findById(id: string): Promise { return this.usersRepository.findOne({ where: { id } }); } + /** True when `plain` matches the user's stored password hash. */ + async verifyPassword(user: User, plain: string): Promise { + return bcrypt.compare(plain, user.password); + } + async findAll(search?: string): Promise { const where = search ? [ @@ -104,6 +125,52 @@ export class UsersService { return this.usersRepository.save(user); } + /** + * Self-service profile update. Email is an optional contact field (not a login + * identifier) so it is set directly here; uniqueness is still enforced. Pass + * `email: null` or '' to clear it. + */ + async updateProfile( + id: string, + data: { firstName?: string; lastName?: string; email?: string | null }, + ): Promise { + const patch: Partial = {}; + if (data.firstName !== undefined) patch.firstName = data.firstName; + if (data.lastName !== undefined) patch.lastName = data.lastName; + if (data.email !== undefined) { + const email = data.email ? data.email.trim().toLowerCase() : null; + if (email) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException('Invalid email address'); + } + const existing = await this.findByEmail(email); + if (existing && existing.id !== id) { + throw new ConflictException('Email already in use'); + } + } + patch.email = email; + } + return this.update(id, patch); + } + + /** Self-service password change — requires the current password. */ + async changeOwnPassword( + id: string, + currentPassword: string, + newPassword: string, + ): Promise { + const user = await this.findById(id); + if (!user) { + throw new NotFoundException('User not found'); + } + const ok = await bcrypt.compare(currentPassword, user.password); + if (!ok) { + throw new UnauthorizedException('Current password is incorrect'); + } + user.password = await bcrypt.hash(newPassword, 12); + await this.usersRepository.save(user); + } + async updateRole(id: string, role: UserRole): Promise { const user = await this.findById(id); if (!user) { diff --git a/backend/src/users/verification.service.ts b/backend/src/users/verification.service.ts new file mode 100644 index 0000000..513e02f --- /dev/null +++ b/backend/src/users/verification.service.ts @@ -0,0 +1,209 @@ +import { + Injectable, + BadRequestException, + ConflictException, + UnauthorizedException, + NotFoundException, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, LessThan, Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { VerificationCode } from './entities/verification-code.entity'; +import { User } from './entities/user.entity'; +import { UsersService } from './users.service'; +import { SmsService } from '../notifications/sms.service'; +import { VerificationPurpose } from '../common/enums'; +import { normalizeIranMobile } from '../common/phone.util'; + +const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const RESEND_COOLDOWN_MS = 60 * 1000; // 60 seconds between requests +const MAX_VERIFY_ATTEMPTS = 5; + +/** + * One-time SMS code flows for phone ownership. + * + * Policy (best practice): + * - Codes are 6 digits, stored hashed, expire in 5 minutes, allow 5 attempts, + * and are rate-limited to one request per minute per (user, purpose). + * - LOGIN codes verify a phone for registration completion and passwordless + * login; CHANGE_PHONE codes verify a new number from the account page (which + * additionally re-authenticates with the current password). + */ +@Injectable() +export class VerificationService { + constructor( + @InjectRepository(VerificationCode) + private readonly codeRepo: Repository, + private readonly usersService: UsersService, + private readonly smsService: SmsService, + ) {} + + // ── Login / registration phone verification ─────────── + + /** Send a LOGIN code to the user's bound phone. */ + async issueLoginOtp(user: User) { + if (!user.phone) { + throw new BadRequestException('Account has no mobile number'); + } + return this.issue({ + userId: user.id, + purpose: VerificationPurpose.LOGIN, + destination: user.phone, + }); + } + + /** Verify a LOGIN code; marks the phone verified on success. */ + async verifyLoginOtp(user: User, code: string) { + await this.consume(user.id, VerificationPurpose.LOGIN, code); + if (!user.phoneVerified) { + await this.usersService.update(user.id, { phoneVerified: true }); + } + } + + // ── Account-page phone change ───────────────────────── + + async requestPhoneChange( + userId: string, + rawPhone: string, + currentPassword: string, + ) { + const phone = normalizeIranMobile(rawPhone); + if (!phone) { + throw new BadRequestException('Invalid mobile number'); + } + await this.assertPassword(userId, currentPassword); + + const existing = await this.usersService.findByPhone(phone); + if (existing && existing.id !== userId) { + throw new ConflictException('Mobile number already in use'); + } + + return this.issue({ + userId, + purpose: VerificationPurpose.CHANGE_PHONE, + destination: phone, + }); + } + + async confirmPhoneChange(userId: string, code: string) { + const record = await this.consume(userId, VerificationPurpose.CHANGE_PHONE, code); + + // Re-check uniqueness at confirm time to close the request→confirm race. + const clash = await this.usersService.findByPhone(record.destination); + if (clash && clash.id !== userId) { + throw new ConflictException('Mobile number already in use'); + } + await this.usersService.update(userId, { + phone: record.destination, + phoneVerified: true, + }); + + const user = await this.usersService.findById(userId); + if (!user) throw new NotFoundException('User not found'); + const { password, ...result } = user; + return result; + } + + // ── internals ───────────────────────────────────────── + + private async assertPassword(userId: string, password: string) { + const user = await this.usersService.findById(userId); + if (!user) throw new NotFoundException('User not found'); + const ok = await this.usersService.verifyPassword(user, password); + if (!ok) throw new UnauthorizedException('Current password is incorrect'); + } + + private async issue(opts: { + userId: string; + purpose: VerificationPurpose; + destination: string; + }) { + const { userId, purpose, destination } = opts; + + // Rate-limit: refuse if a code for this purpose was issued < cooldown ago. + const recent = await this.codeRepo.findOne({ + where: { userId, purpose, consumedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); + if ( + recent && + Date.now() - recent.createdAt.getTime() < RESEND_COOLDOWN_MS && + recent.expiresAt.getTime() > Date.now() + ) { + throw new HttpException( + 'Please wait before requesting another code', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + // Invalidate any prior unconsumed codes for this purpose. + await this.codeRepo.update( + { userId, purpose, consumedAt: IsNull() }, + { consumedAt: new Date() }, + ); + + const code = String(Math.floor(100000 + Math.random() * 900000)); // 6 digits + const expiresAt = new Date(Date.now() + CODE_TTL_MS); + const record = this.codeRepo.create({ + userId, + purpose, + destination, + codeHash: await bcrypt.hash(code, 10), + expiresAt, + attempts: 0, + }); + await this.codeRepo.save(record); + + await this.smsService.sendOtp(destination, code); + + return { destination: this.maskPhone(destination), expiresAt }; + } + + /** Validate and consume the latest active code for (user, purpose). */ + private async consume( + userId: string, + purpose: VerificationPurpose, + code: string, + ): Promise { + const record = await this.codeRepo.findOne({ + where: { userId, purpose, consumedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); + + if (!record || record.expiresAt.getTime() < Date.now()) { + throw new BadRequestException('No active code — request a new one'); + } + if (record.attempts >= MAX_VERIFY_ATTEMPTS) { + record.consumedAt = new Date(); + await this.codeRepo.save(record); + throw new BadRequestException('Too many attempts — request a new code'); + } + + const ok = await bcrypt.compare(code, record.codeHash); + if (!ok) { + record.attempts += 1; + await this.codeRepo.save(record); + throw new BadRequestException('Invalid code'); + } + + record.consumedAt = new Date(); + await this.codeRepo.save(record); + return record; + } + + /** Best-effort cleanup of long-expired codes (called opportunistically). */ + async purgeExpired(): Promise { + await this.codeRepo.delete({ + expiresAt: LessThan(new Date(Date.now() - 24 * 60 * 60 * 1000)), + }); + } + + // +989121234567 -> +9891****4567 + private maskPhone(value: string): string { + return value.length > 8 + ? `${value.slice(0, 5)}****${value.slice(-4)}` + : value; + } +} diff --git a/frontend/src/app/[lang]/dashboard/account/page.tsx b/frontend/src/app/[lang]/dashboard/account/page.tsx new file mode 100644 index 0000000..d54e130 --- /dev/null +++ b/frontend/src/app/[lang]/dashboard/account/page.tsx @@ -0,0 +1,395 @@ +'use client'; + +import { useState } from 'react'; +import api from '@/lib/api'; +import { notify } from '@/lib/notify'; +import { useAuthStore } from '@/lib/store'; +import { useT } from '@/i18n/I18nProvider'; +import type { User } from '@/types'; +import { + UserCircle, + Phone, + Lock, + CheckCircle2, + AlertCircle, + ShieldCheck, +} from 'lucide-react'; + +type Step = 'idle' | 'request' | 'confirm'; + +export default function AccountPage() { + const t = useT(); + const a = t.dashboard.account; + const user = useAuthStore((s) => s.user); + const setUser = useAuthStore((s) => s.setUser); + + if (!user) return null; + + return ( +
+
+

+ + {a.title} +

+

{a.subtitle}

+
+ + + + +
+ ); +} + +/* ── Name + optional email ────────────────────────────── */ + +function ProfileSection({ + user, + onSaved, +}: { + user: User; + onSaved: (u: User) => void; +}) { + const t = useT(); + const a = t.dashboard.account; + const [firstName, setFirstName] = useState(user.firstName); + const [lastName, setLastName] = useState(user.lastName); + const [email, setEmail] = useState(user.email || ''); + const [saving, setSaving] = useState(false); + + const dirty = + firstName !== user.firstName || + lastName !== user.lastName || + email !== (user.email || ''); + + const save = async () => { + setSaving(true); + try { + const { data } = await api.patch('/users/me', { + firstName, + lastName, + email, + }); + onSaved(data); + notify.success(a.savedName); + } catch (err) { + notify.error(err, a.errorGeneric); + } finally { + setSaving(false); + } + }; + + return ( +
+

+ + {a.personalInfo} +

+
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+
+ + setEmail(e.target.value)} + /> +

{a.emailHint}

+
+
+ +
+
+ ); +} + +/* ── Phone (login identifier) with OTP verification ───── */ + +function PhoneSection({ + user, + onChanged, +}: { + user: User; + onChanged: (u: User) => void; +}) { + const t = useT(); + const a = t.dashboard.account; + const c = a.phone; + + const current = user.phone; + const verified = user.phoneVerified; + + const [step, setStep] = useState('idle'); + const [value, setValue] = useState(''); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [masked, setMasked] = useState(''); + const [busy, setBusy] = useState(false); + + const reset = () => { + setStep('idle'); + setValue(''); + setPassword(''); + setCode(''); + setMasked(''); + }; + + const start = () => { + setValue(current || ''); + setStep('request'); + }; + + const requestCode = async () => { + setBusy(true); + try { + const { data } = await api.post('/users/me/phone/request', { + phone: value.trim(), + currentPassword: password, + }); + setMasked(data.destination); + setStep('confirm'); + notify.success(a.codeSent); + } catch (err) { + notify.error(err, a.errorGeneric); + } finally { + setBusy(false); + } + }; + + const confirmCode = async () => { + setBusy(true); + try { + const { data } = await api.post('/users/me/phone/confirm', { + code: code.trim(), + }); + onChanged(data); + notify.success(a.phoneUpdated); + reset(); + } catch (err) { + notify.error(err, a.errorGeneric); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

+ + {c.label} +

+ {current ? ( + verified ? ( + + {a.verified} + + ) : ( + + {a.unverified} + + ) + ) : ( + {a.notSet} + )} +
+ + {step === 'idle' && ( +
+ + {current || {c.empty}} + + +
+ )} + + {step === 'request' && ( +
+
+ + setValue(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +

{a.passwordReason}

+
+
+ + +
+
+ )} + + {step === 'confirm' && ( +
+

+ {a.codeSentTo} {masked} +

+
+ + setCode(e.target.value.replace(/\D/g, ''))} + /> +
+
+ +
+ + +
+
+
+ )} +
+ ); +} + +/* ── Password ─────────────────────────────────────────── */ + +function PasswordSection() { + const t = useT(); + const a = t.dashboard.account; + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (newPassword !== confirm) { + notify.error(a.passwordMismatch); + return; + } + setBusy(true); + try { + await api.post('/users/me/password', { currentPassword, newPassword }); + notify.success(a.passwordChanged); + setCurrentPassword(''); + setNewPassword(''); + setConfirm(''); + } catch (err) { + notify.error(err, a.errorGeneric); + } finally { + setBusy(false); + } + }; + + return ( +
+

+ + {a.changePassword} +

+
+
+ + setCurrentPassword(e.target.value)} + /> +
+
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirm(e.target.value)} + /> +
+
+
+
+ + {a.passwordHint} + + +
+
+ ); +} diff --git a/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx b/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx index b8dddaa..d8cc6cd 100644 --- a/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx @@ -331,7 +331,7 @@ export default function AdminAppsPage() { {`${app.user.firstName} ${app.user.lastName}`} - {app.user.email} + {app.user.email || app.user.phone || ''} {app.userId} ) : ( @@ -486,7 +486,7 @@ export default function AdminAppsPage() { {app.user.firstName} {app.user.lastName}

- {app.user.email} + {app.user.email || app.user.phone || ''} )} diff --git a/frontend/src/app/[lang]/dashboard/layout.tsx b/frontend/src/app/[lang]/dashboard/layout.tsx index 57209e7..c52472d 100644 --- a/frontend/src/app/[lang]/dashboard/layout.tsx +++ b/frontend/src/app/[lang]/dashboard/layout.tsx @@ -32,6 +32,7 @@ import { ScrollText, FileText, Database, + UserCircle, } from 'lucide-react'; type NavKey = keyof Dictionary['nav']; @@ -46,6 +47,7 @@ const userNavItems: NavItem[] = [ { href: '/dashboard/wallet', labelKey: 'wallet', icon: }, { href: '/dashboard/invoices', labelKey: 'invoices', icon: }, { href: '/dashboard/tickets', labelKey: 'tickets', icon: }, + { href: '/dashboard/account', labelKey: 'account', icon: }, ]; const adminNavItems: NavItem[] = [ @@ -203,12 +205,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod {/* Sidebar footer */}
-
+

{user?.firstName} {user?.lastName}

-

{user?.email}

-
+

+ {user?.email || user?.phone} +

+
); diff --git a/frontend/src/app/[lang]/login/page.tsx b/frontend/src/app/[lang]/login/page.tsx index 7c8e951..72902b5 100644 --- a/frontend/src/app/[lang]/login/page.tsx +++ b/frontend/src/app/[lang]/login/page.tsx @@ -6,24 +6,42 @@ import { notify } from '@/lib/notify'; import { ArrowLeft } from 'lucide-react'; import { AuthShell } from '@/components/auth/AuthShell'; import { AuthField } from '@/components/auth/AuthField'; +import { OtpStep } from '@/components/auth/OtpStep'; import { useT } from '@/i18n/I18nProvider'; import { useLocalizedRouter } from '@/i18n/navigation'; +type Method = 'password' | 'otp'; + export default function LoginPage() { const tl = useT().auth.login; - const [email, setEmail] = useState(''); + const [method, setMethod] = useState('password'); + const [phone, setPhone] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); + // When set, we're on the OTP entry step (masked destination to display). + const [otpStep, setOtpStep] = useState(null); + const login = useAuthStore((s) => s.login); + const requestOtp = useAuthStore((s) => s.requestOtp); + const verifyOtp = useAuthStore((s) => s.verifyOtp); const router = useLocalizedRouter(); - const handleSubmit = async (e: React.FormEvent) => { + const finish = () => { + notify.success(tl.success); + router.push('/dashboard'); + }; + + const handlePasswordLogin = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); try { - await login(email, password); - notify.success(tl.success); - router.push('/dashboard'); + const res = await login(phone, password); + if (res.status === 'verify') { + setOtpStep(res.phone); + notify.info(tl.verifyNeeded); + } else { + finish(); + } } catch (err: any) { notify.error(err, tl.error); } finally { @@ -31,6 +49,46 @@ export default function LoginPage() { } }; + const handleOtpRequest = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + await requestOtp(phone); + setOtpStep(phone); + notify.success(tl.codeSent); + } catch (err: any) { + notify.error(err, tl.error); + } finally { + setIsLoading(false); + } + }; + + const handleVerify = async (code: string) => { + setIsLoading(true); + try { + await verifyOtp(phone, code); + finish(); + } catch (err: any) { + notify.error(err, tl.error); + } finally { + setIsLoading(false); + } + }; + + if (otpStep) { + return ( + + requestOtp(phone)} + onBack={() => setOtpStep(null)} + /> + + ); + } + return ( -
- setEmail(e.target.value)} - /> + {/* Method tabs */} +
+ {(['password', 'otp'] as Method[]).map((m) => ( + + ))} +
- setPassword(e.target.value)} - /> - - - + {method === 'password' ? ( +
+ setPhone(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + ) : ( +
+ setPhone(e.target.value)} + /> +

{tl.otpHint}

+ + + )}
); } + +function SubmitButton({ + loading, + label, + loadingLabel, +}: { + loading: boolean; + label: string; + loadingLabel: string; +}) { + return ( + + ); +} diff --git a/frontend/src/app/[lang]/register/page.tsx b/frontend/src/app/[lang]/register/page.tsx index f262c85..eb0db02 100644 --- a/frontend/src/app/[lang]/register/page.tsx +++ b/frontend/src/app/[lang]/register/page.tsx @@ -6,23 +6,35 @@ import { notify } from '@/lib/notify'; import { ArrowLeft } from 'lucide-react'; import { AuthShell } from '@/components/auth/AuthShell'; import { AuthField } from '@/components/auth/AuthField'; +import { OtpStep } from '@/components/auth/OtpStep'; import { useT } from '@/i18n/I18nProvider'; import { useLocalizedRouter } from '@/i18n/navigation'; export default function RegisterPage() { const tr = useT().auth.register; - const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '' }); + const tl = useT().auth.login; + const [form, setForm] = useState({ firstName: '', lastName: '', phone: '', email: '', password: '' }); const [isLoading, setIsLoading] = useState(false); + const [otpStep, setOtpStep] = useState(null); + const register = useAuthStore((s) => s.register); + const requestOtp = useAuthStore((s) => s.requestOtp); + const verifyOtp = useAuthStore((s) => s.verifyOtp); const router = useLocalizedRouter(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); try { - await register(form); - notify.success(tr.success); - router.push('/dashboard'); + const { phone } = await register({ + firstName: form.firstName, + lastName: form.lastName, + phone: form.phone.trim(), + password: form.password, + ...(form.email.trim() ? { email: form.email.trim() } : {}), + }); + setOtpStep(phone); + notify.success(tr.codeSent); } catch (err: any) { notify.error(err, tr.error); } finally { @@ -30,6 +42,33 @@ export default function RegisterPage() { } }; + const handleVerify = async (code: string) => { + setIsLoading(true); + try { + await verifyOtp(form.phone.trim(), code); + notify.success(tr.success); + router.push('/dashboard'); + } catch (err: any) { + notify.error(err, tr.verifyError); + } finally { + setIsLoading(false); + } + }; + + if (otpStep) { + return ( + + requestOtp(form.phone.trim())} + onBack={() => setOtpStep(null)} + /> + + ); + } + return ( setForm({ ...form, phone: e.target.value })} + /> + + ) { return ( @@ -14,7 +16,10 @@ export function AuthField({ ); diff --git a/frontend/src/components/auth/AuthShell.tsx b/frontend/src/components/auth/AuthShell.tsx index cfbf6d1..ed30818 100644 --- a/frontend/src/components/auth/AuthShell.tsx +++ b/frontend/src/components/auth/AuthShell.tsx @@ -21,9 +21,9 @@ export function AuthShell({ title: string; subtitle: string; children: ReactNode; - altPrompt: string; - altHref: string; - altLabel: string; + altPrompt?: string; + altHref?: string; + altLabel?: string; }) { const t = useT(); return ( @@ -69,12 +69,14 @@ export function AuthShell({
{children}
-

- {altPrompt}{' '} - - {altLabel} - -

+ {altPrompt && altHref && altLabel && ( +

+ {altPrompt}{' '} + + {altLabel} + +

+ )} diff --git a/frontend/src/components/auth/OtpStep.tsx b/frontend/src/components/auth/OtpStep.tsx new file mode 100644 index 0000000..303c14d --- /dev/null +++ b/frontend/src/components/auth/OtpStep.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { ArrowLeft } from 'lucide-react'; +import { AuthField } from '@/components/auth/AuthField'; +import { useT } from '@/i18n/I18nProvider'; + +/** + * Shared 6-digit OTP entry used by both registration completion and OTP login. + * Manages the code input, a verify action, and a resend button with a cooldown. + */ +export function OtpStep({ + destination, + onVerify, + onResend, + onBack, + submitting, +}: { + destination: string; + onVerify: (code: string) => void | Promise; + onResend: () => void | Promise; + onBack: () => void; + submitting: boolean; +}) { + const t = useT().auth.otp; + const [code, setCode] = useState(''); + const [cooldown, setCooldown] = useState(60); + const timer = useRef | null>(null); + + useEffect(() => { + timer.current = setInterval(() => { + setCooldown((c) => (c > 0 ? c - 1 : 0)); + }, 1000); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, []); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + onVerify(code.trim()); + }; + + const resend = async () => { + if (cooldown > 0) return; + await onResend(); + setCooldown(60); + }; + + return ( +
+

+ {t.sentTo}{' '} + + {destination} + +

+ + setCode(e.target.value.replace(/\D/g, ''))} + /> + + + +
+ + +
+ + ); +} diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index e6dc96a..2868c49 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -120,11 +120,20 @@ const en: Dictionary = { backHome: 'Back to home', login: { title: 'Welcome back', - subtitle: 'Sign in to your Abrban account', + subtitle: 'Sign in to your Abrban account with your mobile', altPrompt: 'Don’t have an account yet?', altLabel: 'Sign up', - email: 'Email', + tabPassword: 'With password', + tabOtp: 'With one-time code', + phone: 'Mobile number', password: 'Password', + otpHint: 'A verification code will be texted to your mobile.', + sendCode: 'Send code', + sending: 'Sending…', + codeSent: 'Verification code sent', + verifyNeeded: 'Verify your mobile number to continue.', + otpTitle: 'Verify mobile number', + otpSubtitle: 'Enter the 6-digit code we texted you', submit: 'Sign in', submitting: 'Signing in…', success: 'Signed in successfully!', @@ -139,13 +148,25 @@ const en: Dictionary = { firstNamePlaceholder: 'e.g. Ali', lastName: 'Last name', lastNamePlaceholder: 'e.g. Rezaei', - email: 'Email', + phone: 'Mobile number', + emailOptional: 'Email (optional)', password: 'Password', passwordPlaceholder: 'At least 8 characters', submit: 'Create account', submitting: 'Creating account…', - success: 'Account created successfully!', + codeSent: 'A verification code was texted to your mobile', + success: 'Account created and verified!', error: 'Sign up failed', + verifyError: 'Code verification failed', + }, + otp: { + sentTo: 'A verification code was texted to:', + codeLabel: 'Verification code', + verify: 'Verify and continue', + verifying: 'Verifying…', + back: 'Back', + resend: 'Resend code', + resendIn: 'Resend in {s}s', }, }, @@ -391,6 +412,7 @@ const en: Dictionary = { wallet: 'Wallet', invoices: 'Invoices', tickets: 'Tickets', + account: 'My Account', users: 'Users', allApplications: 'All Applications', billingPlans: 'Billing Plans', @@ -402,6 +424,48 @@ const en: Dictionary = { }, dashboard: { + account: { + title: 'My Account', + subtitle: 'Manage your account details, mobile number and password.', + personalInfo: 'Personal information', + firstName: 'First name', + lastName: 'Last name', + emailOptional: 'Email (optional)', + emailHint: 'Email is for contact only and is not used to sign in.', + save: 'Save', + saving: 'Saving…', + savedName: 'Profile saved successfully', + verified: 'Verified', + unverified: 'Unverified', + notSet: 'Not set', + verifyNow: 'Verify', + currentPassword: 'Current password', + passwordReason: 'Your current password is required to change your mobile number.', + sendCode: 'Send code', + sending: 'Sending…', + codeSent: 'Verification code sent', + codeSentTo: 'A verification code was texted to:', + enterCode: 'Verification code', + confirm: 'Confirm', + verifying: 'Verifying…', + resend: 'Resend code', + cancel: 'Cancel', + phoneUpdated: 'Mobile number updated successfully', + changePassword: 'Change password', + newPassword: 'New password', + confirmPassword: 'Confirm new password', + passwordMismatch: 'New password and confirmation do not match', + passwordChanged: 'Password changed successfully', + passwordHint: 'At least 8 characters', + errorGeneric: 'Operation failed', + phone: { + label: 'Mobile number', + empty: 'No number set', + add: 'Add number', + change: 'Change number', + newLabel: 'New mobile number', + }, + }, status: { running: 'Running', pending: 'Pending', @@ -679,6 +743,9 @@ const en: Dictionary = { notAvailableMessage: 'Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app, and ask an administrator to deploy the logging stack.', checkingConnection: 'Checking connection…', retryingAuto: 'Retrying automatically every few seconds.', + elasticDisabledTitle: 'Elasticsearch logging is off', + elasticDisabledMessage: 'You did not enable Elasticsearch for this application when creating it, so centralized logs are unavailable. Enable the Elasticsearch addon when deploying a new app to see its logs here.', + elasticDisabledMessageAll: 'None of your applications have Elasticsearch enabled, so centralized logs are unavailable. Enable the Elasticsearch addon when deploying a new app to see its logs here.', }, users: { title: 'User Management', diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index 39e94a2..54094f5 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -119,11 +119,20 @@ const fa = { backHome: 'بازگشت به خانه', login: { title: 'خوش آمدی', - subtitle: 'به حساب ابربان خود وارد شو', + subtitle: 'با شماره موبایل وارد حساب ابربان شو', altPrompt: 'هنوز حساب نداری؟', altLabel: 'ثبت‌نام کن', - email: 'ایمیل', + tabPassword: 'با رمز عبور', + tabOtp: 'با رمز یکبارمصرف', + phone: 'شماره موبایل', password: 'رمز عبور', + otpHint: 'یک کد تأیید به شماره موبایلت پیامک می‌شود.', + sendCode: 'ارسال کد', + sending: 'در حال ارسال…', + codeSent: 'کد تأیید پیامک شد', + verifyNeeded: 'برای ادامه، شماره موبایلت را تأیید کن.', + otpTitle: 'تأیید شماره موبایل', + otpSubtitle: 'کد ۶ رقمی پیامک‌شده را وارد کن', submit: 'ورود', submitting: 'در حال ورود…', success: 'با موفقیت وارد شدی!', @@ -138,13 +147,25 @@ const fa = { firstNamePlaceholder: 'مثلاً علی', lastName: 'نام خانوادگی', lastNamePlaceholder: 'مثلاً رضایی', - email: 'ایمیل', + phone: 'شماره موبایل', + emailOptional: 'ایمیل (اختیاری)', password: 'رمز عبور', passwordPlaceholder: 'حداقل ۸ کاراکتر', submit: 'ساخت حساب', submitting: 'در حال ساخت حساب…', - success: 'حساب با موفقیت ساخته شد!', + codeSent: 'کد تأیید به موبایلت پیامک شد', + success: 'حساب با موفقیت ساخته و تأیید شد!', error: 'ثبت‌نام ناموفق بود', + verifyError: 'تأیید کد ناموفق بود', + }, + otp: { + sentTo: 'کد تأیید به این شماره پیامک شد:', + codeLabel: 'کد تأیید', + verify: 'تأیید و ادامه', + verifying: 'در حال بررسی…', + back: 'بازگشت', + resend: 'ارسال مجدد کد', + resendIn: 'ارسال مجدد تا {s} ثانیه', }, }, @@ -390,6 +411,7 @@ const fa = { wallet: 'کیف‌پول', invoices: 'فاکتورها', tickets: 'تیکت‌ها', + account: 'حساب من', users: 'کاربران', allApplications: 'همهٔ اپلیکیشن‌ها', billingPlans: 'پلن‌های صورت‌حساب', @@ -401,6 +423,48 @@ const fa = { }, dashboard: { + account: { + title: 'حساب من', + subtitle: 'اطلاعات حساب، شماره موبایل و رمز عبورت را مدیریت کن.', + personalInfo: 'اطلاعات شخصی', + firstName: 'نام', + lastName: 'نام خانوادگی', + emailOptional: 'ایمیل (اختیاری)', + emailHint: 'ایمیل فقط برای ارتباط است و در ورود استفاده نمی‌شود.', + save: 'ذخیره', + saving: 'در حال ذخیره…', + savedName: 'اطلاعات با موفقیت ذخیره شد', + verified: 'تأیید شده', + unverified: 'تأیید نشده', + notSet: 'ثبت نشده', + verifyNow: 'تأیید', + currentPassword: 'رمز عبور فعلی', + passwordReason: 'برای تغییر شماره موبایل، رمز فعلی لازم است.', + sendCode: 'ارسال کد', + sending: 'در حال ارسال…', + codeSent: 'کد تأیید پیامک شد', + codeSentTo: 'کد تأیید به این شماره پیامک شد:', + enterCode: 'کد تأیید', + confirm: 'تأیید', + verifying: 'در حال بررسی…', + resend: 'ارسال مجدد کد', + cancel: 'انصراف', + phoneUpdated: 'شماره موبایل با موفقیت به‌روزرسانی شد', + changePassword: 'تغییر رمز عبور', + newPassword: 'رمز عبور جدید', + confirmPassword: 'تکرار رمز عبور جدید', + passwordMismatch: 'رمز عبور جدید و تکرار آن یکسان نیستند', + passwordChanged: 'رمز عبور با موفقیت تغییر کرد', + passwordHint: 'حداقل ۸ کاراکتر', + errorGeneric: 'انجام عملیات ناموفق بود', + phone: { + label: 'شماره موبایل', + empty: 'شماره‌ای ثبت نشده', + add: 'افزودن شماره', + change: 'تغییر شماره', + newLabel: 'شماره موبایل جدید', + }, + }, status: { running: 'در حال اجرا', pending: 'در انتظار', @@ -678,6 +742,9 @@ const fa = { notAvailableMessage: 'Elasticsearch مرکزی روی کلاستر مستقر نشده است. هنگام انتشار اپ، افزونهٔ لاگینگ را فعال کن و از مدیر بخواه استک لاگینگ را مستقر کند.', checkingConnection: 'در حال بررسی اتصال…', retryingAuto: 'هر چند ثانیه به‌صورت خودکار تلاش می‌شود.', + elasticDisabledTitle: 'لاگ‌گیری Elasticsearch فعال نیست', + elasticDisabledMessage: 'برای این اپلیکیشن هنگام ساخت، گزینهٔ Elasticsearch را فعال نکرده‌ای؛ به همین دلیل لاگ متمرکز در دسترس نیست. هنگام انتشار یک اپ جدید افزونهٔ Elasticsearch را فعال کن تا لاگ‌های آن اینجا نمایش داده شود.', + elasticDisabledMessageAll: 'هیچ‌کدام از اپلیکیشن‌هایت هنگام ساخت، Elasticsearch را فعال نکرده‌اند؛ به همین دلیل لاگ متمرکز در دسترس نیست. هنگام انتشار یک اپ جدید افزونهٔ Elasticsearch را فعال کن تا لاگ‌های آن اینجا نمایش داده شود.', }, users: { title: 'مدیریت کاربران', diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index b88b10d..2b2f059 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -4,14 +4,44 @@ import { create } from 'zustand'; import api from '@/lib/api'; import type { User, AuthResponse } from '@/types'; +export interface RegisterData { + phone: string; + password: string; + firstName: string; + lastName: string; + email?: string; +} + +/** Either the user is fully authenticated, or a phone OTP is required next. */ +export type AuthResult = + | { status: 'authenticated' } + | { status: 'verify'; phone: string }; + +interface VerificationRequired { + requiresVerification: true; + phone: string; +} + interface AuthState { user: User | null; isLoading: boolean; isAuthenticated: boolean; - login: (email: string, password: string) => Promise; - register: (data: { email: string; password: string; firstName: string; lastName: string }) => Promise; + /** Password login by mobile. May require OTP verification. */ + login: (phone: string, password: string) => Promise; + /** Register by mobile — always returns a verify step. */ + register: (data: RegisterData) => Promise<{ phone: string }>; + /** Send a one-time login code to a mobile number. */ + requestOtp: (phone: string) => Promise; + /** Verify a one-time code (completes registration or OTP login). */ + verifyOtp: (phone: string, code: string) => Promise; logout: () => void; loadUser: () => Promise; + setUser: (user: User) => void; +} + +function persistAuth(data: AuthResponse) { + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); } export const useAuthStore = create((set) => ({ @@ -19,17 +49,31 @@ export const useAuthStore = create((set) => ({ isLoading: true, isAuthenticated: false, - login: async (email, password) => { - const { data } = await api.post('/auth/login', { email, password }); - localStorage.setItem('accessToken', data.accessToken); - localStorage.setItem('refreshToken', data.refreshToken); + login: async (phone, password) => { + const { data } = await api.post( + '/auth/login', + { phone, password }, + ); + if ('requiresVerification' in data) { + return { status: 'verify', phone: data.phone }; + } + persistAuth(data); set({ user: data.user, isAuthenticated: true }); + return { status: 'authenticated' }; }, register: async (registerData) => { - const { data } = await api.post('/auth/register', registerData); - localStorage.setItem('accessToken', data.accessToken); - localStorage.setItem('refreshToken', data.refreshToken); + const { data } = await api.post('/auth/register', registerData); + return { phone: data.phone }; + }, + + requestOtp: async (phone) => { + await api.post('/auth/otp/request', { phone }); + }, + + verifyOtp: async (phone, code) => { + const { data } = await api.post('/auth/otp/verify', { phone, code }); + persistAuth(data); set({ user: data.user, isAuthenticated: true }); }, @@ -52,4 +96,6 @@ export const useAuthStore = create((set) => ({ set({ user: null, isAuthenticated: false, isLoading: false }); } }, + + setUser: (user) => set({ user }), })); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9820b3b..c12a528 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,6 +1,8 @@ export interface User { id: string; - email: string; + phone?: string | null; + email: string | null; + phoneVerified?: boolean; firstName: string; lastName: string; role: 'user' | 'admin' | 'technical' | 'sales';