feat(auth): mobile-only register/login with OTP verification

- Register and login by mobile number; email is now an optional
  contact field only (never used to authenticate)
- After registration, the phone is verified via a 6-digit SMS code
- Login supports both password and one-time-code (OTP) methods
- Phone OTP delivered via Kavenegar (verify/lookup); API key in env
- Account page: edit name/optional email, change password, and
  change mobile number with OTP re-verification
- Codes are hashed, expire in 5m, capped at 5 attempts, rate-limited
- Seed gives the admin a verified phone so mobile login still works

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-16 16:40:08 +03:30
parent ce6813db99
commit 37c103fa20
31 changed files with 1756 additions and 143 deletions
+10
View File
@@ -20,6 +20,16 @@ JWT_REFRESH_EXPIRES_IN=7d
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 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) # In-cluster Docker Registry (Kaniko push + app image pull — same URL)
REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000 REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000
# REGISTRY_PULL_URL=registry.cloudhost-builds.svc.cluster.local:5000 # REGISTRY_PULL_URL=registry.cloudhost-builds.svc.cluster.local:5000
@@ -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);
+21 -5
View File
@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Authentication') @ApiTags('Authentication')
@@ -11,22 +12,37 @@ export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register a new user' }) @ApiOperation({ summary: 'Register with a mobile number (sends a verification code)' })
@ApiResponse({ status: 201, description: 'User registered successfully' }) @ApiResponse({ status: 201, description: 'Account created; phone verification required' })
@ApiResponse({ status: 409, description: 'Email already registered' }) @ApiResponse({ status: 409, description: 'Mobile number already registered' })
async register(@Body() registerDto: RegisterDto) { async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto); return this.authService.register(registerDto);
} }
@Post('login') @Post('login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' }) @ApiOperation({ summary: 'Login with mobile and password' })
@ApiResponse({ status: 200, description: 'Login successful' }) @ApiResponse({ status: 200, description: 'Login successful (or verification required)' })
@ApiResponse({ status: 401, description: 'Invalid credentials' }) @ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() loginDto: LoginDto) { async login(@Body() loginDto: LoginDto) {
return this.authService.login(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') @Post('refresh')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' }) @ApiOperation({ summary: 'Refresh access token' })
+110 -32
View File
@@ -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 { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { VerificationService } from '../users/verification.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.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() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
private verificationService: VerificationService,
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService, private configService: ConfigService,
) {} ) {}
async register(registerDto: RegisterDto) { /**
const existingUser = await this.usersService.findByEmail(registerDto.email); * Create an account from a mobile number. Email is optional contact info only.
if (existingUser) { * The account starts unverified; a LOGIN OTP is sent and must be confirmed via
throw new ConflictException('Email already registered'); * `verifyOtp` before tokens are issued.
*/
async register(registerDto: RegisterDto): Promise<VerificationRequired> {
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 hashedPassword = await bcrypt.hash(registerDto.password, 12);
const user = await this.usersService.create({ const user = await this.usersService.create({
...registerDto, phone,
email,
firstName: registerDto.firstName,
lastName: registerDto.lastName,
password: hashedPassword, password: hashedPassword,
phoneVerified: false,
}); });
const tokens = await this.generateTokens(user.id, user.email, user.role); const { destination } = await this.verificationService.issueLoginOtp(user);
return { return { requiresVerification: true, phone: destination };
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
...tokens,
};
} }
/**
* 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) { 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) { if (!user) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
@@ -54,17 +86,46 @@ export class AuthService {
throw new UnauthorizedException('Account is deactivated'); throw new UnauthorizedException('Account is deactivated');
} }
const tokens = await this.generateTokens(user.id, user.email, user.role); if (!user.phoneVerified) {
return { const { destination } = await this.verificationService.issueLoginOtp(user);
user: { return { requiresVerification: true, phone: destination } as VerificationRequired;
id: user.id, }
email: user.email,
firstName: user.firstName, const tokens = await this.generateTokens(user);
lastName: user.lastName, return { user: this.publicUser(user), ...tokens };
role: user.role, }
},
...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) { async refreshToken(refreshToken: string) {
@@ -78,14 +139,31 @@ export class AuthService {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
return this.generateTokens(user.id, user.email, user.role); return this.generateTokens(user);
} catch { } catch {
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedException('Invalid refresh token');
} }
} }
private async generateTokens(userId: string, email: string, role: string) { private publicUser(user: User) {
const payload = { sub: userId, email, role }; 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([ const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload), this.jwtService.signAsync(payload),
+4 -4
View File
@@ -1,10 +1,10 @@
import { IsEmail, IsString } from 'class-validator'; import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class LoginDto { export class LoginDto {
@ApiProperty({ example: 'john@example.com' }) @ApiProperty({ example: '09121234567' })
@IsEmail() @IsString()
email: string; phone: string;
@ApiProperty({ example: 'SecureP@ss123' }) @ApiProperty({ example: 'SecureP@ss123' })
@IsString() @IsString()
+20
View File
@@ -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;
}
+16 -5
View File
@@ -1,10 +1,16 @@
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator'; import {
import { ApiProperty } from '@nestjs/swagger'; IsEmail,
IsString,
MinLength,
MaxLength,
IsOptional,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto { export class RegisterDto {
@ApiProperty({ example: 'john@example.com' }) @ApiProperty({ example: '09121234567' })
@IsEmail() @IsString()
email: string; phone: string;
@ApiProperty({ example: 'SecureP@ss123' }) @ApiProperty({ example: 'SecureP@ss123' })
@IsString() @IsString()
@@ -23,4 +29,9 @@ export class RegisterDto {
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(50)
lastName: string; lastName: string;
@ApiPropertyOptional({ example: 'john@example.com' })
@IsOptional()
@IsEmail()
email?: string;
} }
+8
View File
@@ -7,6 +7,14 @@ export enum UserRole {
SALES = 'sales', 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 { export enum TicketDepartment {
TECHNICAL = 'technical', TECHNICAL = 'technical',
SALES = 'sales', SALES = 'sales',
+53
View File
@@ -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)}`;
}
+6
View File
@@ -84,6 +84,12 @@ export default () => ({
port: parseInt(process.env.REDIS_PORT || '6379', 10), 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: { registry: {
/** In-cluster registry — Kaniko push and app image pull (same host). */ /** In-cluster registry — Kaniko push and app image pull (same host). */
url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class NotificationsModule {}
+64
View File
@@ -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<string>('sms.kavenegarApiKey') || undefined;
}
private get template(): string | undefined {
return this.config.get<string>('sms.kavenegarOtpTemplate') || undefined;
}
isConfigured(): boolean {
return Boolean(this.apiKey && this.template);
}
async sendOtp(phoneE164: string, code: string): Promise<void> {
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');
}
}
}
+16 -3
View File
@@ -6,6 +6,7 @@ import { AppModule } from './app.module';
import { UsersService } from './users/users.service'; import { UsersService } from './users/users.service';
import { PlatformSetting } from './billing/entities/platform-setting.entity'; import { PlatformSetting } from './billing/entities/platform-setting.entity';
import { UserRole } from './common/enums'; import { UserRole } from './common/enums';
import { normalizeIranMobile } from './common/phone.util';
/** /**
* Seed script — creates the initial super admin user. * 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 email = process.env.ADMIN_EMAIL || 'test@example.com';
const password = process.env.ADMIN_PASSWORD || 'Admin123!'; 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); const existing = await usersService.findByEmail(email);
if (existing) { if (existing) {
console.log(`⚠️ Admin user already exists: ${email} (role: ${existing.role})`); console.log(`⚠️ Admin user already exists: ${email} (role: ${existing.role})`);
if (existing.role !== UserRole.ADMIN) { const patch: any = {};
await usersService.update(existing.id, { role: UserRole.ADMIN }); if (existing.role !== UserRole.ADMIN) patch.role = UserRole.ADMIN;
console.log(`✅ Promoted ${email} to 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 { } else {
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
await usersService.create({ await usersService.create({
email, email,
phone,
phoneVerified: true,
password: hashedPassword, password: hashedPassword,
firstName: 'Super', firstName: 'Super',
lastName: 'Admin', lastName: 'Admin',
@@ -47,6 +59,7 @@ async function bootstrap() {
} }
console.log(`\n📋 Admin credentials:`); console.log(`\n📋 Admin credentials:`);
console.log(` Mobile: ${phone}`);
console.log(` Email: ${email}`); console.log(` Email: ${email}`);
console.log(` Password: ${password}`); console.log(` Password: ${password}`);
+12 -2
View File
@@ -14,8 +14,18 @@ export class User {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ unique: true }) // Phone is the login identifier (required at registration). Email is an
email: string; // 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() @Column()
password: string; password: string;
@@ -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;
}
+90 -1
View File
@@ -13,6 +13,7 @@ import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator'; import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { VerificationService } from './verification.service';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums'; import { UserRole } from '../common/enums';
@@ -44,6 +45,51 @@ class UpdateRoleDto {
role: UserRole; 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 { class AdminSetPasswordDto {
@IsString() @IsString()
@MinLength(8) @MinLength(8)
@@ -56,7 +102,10 @@ class AdminSetPasswordDto {
@Controller('users') @Controller('users')
@UseGuards(AuthGuard('jwt'), RolesGuard) @UseGuards(AuthGuard('jwt'), RolesGuard)
export class UsersController { export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(
private readonly usersService: UsersService,
private readonly verificationService: VerificationService,
) {}
@Get('me') @Get('me')
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@@ -69,6 +118,46 @@ export class UsersController {
return null; 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() @Get()
@Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES) @Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES)
@ApiOperation({ summary: 'List all users with optional search (Staff/Admin)' }) @ApiOperation({ summary: 'List all users with optional search (Staff/Admin)' })
+9 -3
View File
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { VerificationService } from './verification.service';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
import { VerificationCode } from './entities/verification-code.entity';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User])], imports: [
TypeOrmModule.forFeature([User, VerificationCode]),
NotificationsModule,
],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService], providers: [UsersService, VerificationService],
exports: [UsersService], exports: [UsersService, VerificationService],
}) })
export class UsersModule {} export class UsersModule {}
+68 -1
View File
@@ -3,6 +3,8 @@ import {
NotFoundException, NotFoundException,
ConflictException, ConflictException,
ForbiddenException, ForbiddenException,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike } from 'typeorm'; import { Repository, ILike } from 'typeorm';
@@ -50,13 +52,32 @@ export class UsersService {
} }
async findByEmail(email: string): Promise<User | null> { async findByEmail(email: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { email } }); return this.usersRepository.findOne({ where: { email: email.toLowerCase() } });
}
async findByPhone(phone: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { phone } });
}
/** Login lookup: match against whichever identifier was supplied. */
async findByEmailOrPhone(opts: {
email?: string;
phone?: string;
}): Promise<User | null> {
if (opts.email) return this.findByEmail(opts.email);
if (opts.phone) return this.findByPhone(opts.phone);
return null;
} }
async findById(id: string): Promise<User | null> { async findById(id: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { id } }); return this.usersRepository.findOne({ where: { id } });
} }
/** True when `plain` matches the user's stored password hash. */
async verifyPassword(user: User, plain: string): Promise<boolean> {
return bcrypt.compare(plain, user.password);
}
async findAll(search?: string): Promise<any[]> { async findAll(search?: string): Promise<any[]> {
const where = search const where = search
? [ ? [
@@ -104,6 +125,52 @@ export class UsersService {
return this.usersRepository.save(user); 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<User> {
const patch: Partial<User> = {};
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<void> {
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<void> { async updateRole(id: string, role: UserRole): Promise<void> {
const user = await this.findById(id); const user = await this.findById(id);
if (!user) { if (!user) {
+209
View File
@@ -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<VerificationCode>,
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<VerificationCode> {
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<void> {
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;
}
}
@@ -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 (
<div className="max-w-3xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<UserCircle className="w-6 h-6 text-primary-600" />
{a.title}
</h1>
<p className="text-sm text-gray-500 mt-1">{a.subtitle}</p>
</div>
<ProfileSection user={user} onSaved={setUser} />
<PhoneSection user={user} onChanged={setUser} />
<PasswordSection />
</div>
);
}
/* ── 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<User>('/users/me', {
firstName,
lastName,
email,
});
onSaved(data);
notify.success(a.savedName);
} catch (err) {
notify.error(err, a.errorGeneric);
} finally {
setSaving(false);
}
};
return (
<section className="card space-y-4">
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
<UserCircle className="w-4 h-4 text-gray-400" />
{a.personalInfo}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.firstName}</label>
<input className="input-field" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.lastName}</label>
<input className="input-field" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.emailOptional}</label>
<input
className="input-field"
dir="ltr"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<p className="text-xs text-gray-400 mt-1">{a.emailHint}</p>
</div>
<div className="flex justify-end">
<button
className="btn-primary"
disabled={!dirty || saving || !firstName.trim() || !lastName.trim()}
onClick={save}
>
{saving ? a.saving : a.save}
</button>
</div>
</section>
);
}
/* ── 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<Step>('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<User>('/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 (
<section className="card space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
<Phone className="w-4 h-4 text-gray-400" />
{c.label}
</h2>
{current ? (
verified ? (
<span className="badge-green inline-flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> {a.verified}
</span>
) : (
<span className="badge-yellow inline-flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {a.unverified}
</span>
)
) : (
<span className="badge-gray">{a.notSet}</span>
)}
</div>
{step === 'idle' && (
<div className="flex items-center justify-between gap-3">
<span dir="ltr" className="text-sm text-gray-700 font-mono">
{current || <span className="text-gray-400">{c.empty}</span>}
</span>
<button className="btn-secondary" onClick={start}>
{current ? (verified ? c.change : a.verifyNow) : c.add}
</button>
</div>
)}
{step === 'request' && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{c.newLabel}</label>
<input
className="input-field"
dir="ltr"
type="tel"
placeholder="09123456789"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.currentPassword}</label>
<input
className="input-field"
dir="ltr"
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-gray-400 mt-1">{a.passwordReason}</p>
</div>
<div className="flex justify-end gap-2">
<button className="btn-ghost" onClick={reset}>
{a.cancel}
</button>
<button
className="btn-primary"
disabled={busy || !value.trim() || !password}
onClick={requestCode}
>
{busy ? a.sending : a.sendCode}
</button>
</div>
</div>
)}
{step === 'confirm' && (
<div className="space-y-3">
<p className="text-sm text-gray-600">
{a.codeSentTo} <span dir="ltr" className="font-mono font-semibold">{masked}</span>
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.enterCode}</label>
<input
className="input-field tracking-[0.4em] text-center font-mono"
dir="ltr"
inputMode="numeric"
maxLength={6}
placeholder="------"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
/>
</div>
<div className="flex items-center justify-between gap-2">
<button className="btn-ghost text-xs" onClick={requestCode} disabled={busy}>
{a.resend}
</button>
<div className="flex gap-2">
<button className="btn-ghost" onClick={reset}>
{a.cancel}
</button>
<button
className="btn-primary"
disabled={busy || code.length < 4}
onClick={confirmCode}
>
{busy ? a.verifying : a.confirm}
</button>
</div>
</div>
</div>
)}
</section>
);
}
/* ── 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 (
<section className="card space-y-4">
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
<Lock className="w-4 h-4 text-gray-400" />
{a.changePassword}
</h2>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.currentPassword}</label>
<input
className="input-field"
dir="ltr"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.newPassword}</label>
<input
className="input-field"
dir="ltr"
type="password"
autoComplete="new-password"
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.confirmPassword}</label>
<input
className="input-field"
dir="ltr"
type="password"
autoComplete="new-password"
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-gray-400 inline-flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" /> {a.passwordHint}
</span>
<button
className="btn-primary"
disabled={busy || !currentPassword || newPassword.length < 8 || !confirm}
onClick={submit}
>
{busy ? a.saving : a.changePassword}
</button>
</div>
</section>
);
}
@@ -331,7 +331,7 @@ export default function AdminAppsPage() {
<TruncatedText className="text-sm font-medium text-gray-900"> <TruncatedText className="text-sm font-medium text-gray-900">
{`${app.user.firstName} ${app.user.lastName}`} {`${app.user.firstName} ${app.user.lastName}`}
</TruncatedText> </TruncatedText>
<TruncatedText className="text-xs text-gray-400">{app.user.email}</TruncatedText> <TruncatedText className="text-xs text-gray-400">{app.user.email || app.user.phone || ''}</TruncatedText>
<TruncatedText className="text-xs text-gray-300 font-mono">{app.userId}</TruncatedText> <TruncatedText className="text-xs text-gray-300 font-mono">{app.userId}</TruncatedText>
</div> </div>
) : ( ) : (
@@ -486,7 +486,7 @@ export default function AdminAppsPage() {
<User className="w-3 h-3 shrink-0" /> <User className="w-3 h-3 shrink-0" />
{app.user.firstName} {app.user.lastName} {app.user.firstName} {app.user.lastName}
</p> </p>
<TruncatedText>{app.user.email}</TruncatedText> <TruncatedText>{app.user.email || app.user.phone || ''}</TruncatedText>
</div> </div>
)} )}
+10 -3
View File
@@ -32,6 +32,7 @@ import {
ScrollText, ScrollText,
FileText, FileText,
Database, Database,
UserCircle,
} from 'lucide-react'; } from 'lucide-react';
type NavKey = keyof Dictionary['nav']; type NavKey = keyof Dictionary['nav'];
@@ -46,6 +47,7 @@ const userNavItems: NavItem[] = [
{ href: '/dashboard/wallet', labelKey: 'wallet', icon: <Wallet className="w-4 h-4" /> }, { href: '/dashboard/wallet', labelKey: 'wallet', icon: <Wallet className="w-4 h-4" /> },
{ href: '/dashboard/invoices', labelKey: 'invoices', icon: <FileText className="w-4 h-4" /> }, { href: '/dashboard/invoices', labelKey: 'invoices', icon: <FileText className="w-4 h-4" /> },
{ href: '/dashboard/tickets', labelKey: 'tickets', icon: <Ticket className="w-4 h-4" /> }, { href: '/dashboard/tickets', labelKey: 'tickets', icon: <Ticket className="w-4 h-4" /> },
{ href: '/dashboard/account', labelKey: 'account', icon: <UserCircle className="w-4 h-4" /> },
]; ];
const adminNavItems: NavItem[] = [ const adminNavItems: NavItem[] = [
@@ -203,12 +205,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{/* Sidebar footer */} {/* Sidebar footer */}
<div className="pt-4 mt-4 border-t border-gray-200 shrink-0"> <div className="pt-4 mt-4 border-t border-gray-200 shrink-0">
<div className="px-3 py-2"> <Link
href="/dashboard/account"
className="block px-3 py-2 rounded-xl hover:bg-gray-100 transition-colors"
>
<p className="text-xs font-semibold text-gray-700 truncate"> <p className="text-xs font-semibold text-gray-700 truncate">
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</p> </p>
<p className="text-xs text-gray-400 truncate">{user?.email}</p> <p className="text-xs text-gray-400 truncate" dir="ltr">
</div> {user?.email || user?.phone}
</p>
</Link>
</div> </div>
</div> </div>
); );
+126 -20
View File
@@ -6,24 +6,42 @@ import { notify } from '@/lib/notify';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { AuthShell } from '@/components/auth/AuthShell'; import { AuthShell } from '@/components/auth/AuthShell';
import { AuthField } from '@/components/auth/AuthField'; import { AuthField } from '@/components/auth/AuthField';
import { OtpStep } from '@/components/auth/OtpStep';
import { useT } from '@/i18n/I18nProvider'; import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation'; import { useLocalizedRouter } from '@/i18n/navigation';
type Method = 'password' | 'otp';
export default function LoginPage() { export default function LoginPage() {
const tl = useT().auth.login; const tl = useT().auth.login;
const [email, setEmail] = useState(''); const [method, setMethod] = useState<Method>('password');
const [phone, setPhone] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// When set, we're on the OTP entry step (masked destination to display).
const [otpStep, setOtpStep] = useState<string | null>(null);
const login = useAuthStore((s) => s.login); const login = useAuthStore((s) => s.login);
const requestOtp = useAuthStore((s) => s.requestOtp);
const verifyOtp = useAuthStore((s) => s.verifyOtp);
const router = useLocalizedRouter(); 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(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
try { try {
await login(email, password); const res = await login(phone, password);
notify.success(tl.success); if (res.status === 'verify') {
router.push('/dashboard'); setOtpStep(res.phone);
notify.info(tl.verifyNeeded);
} else {
finish();
}
} catch (err: any) { } catch (err: any) {
notify.error(err, tl.error); notify.error(err, tl.error);
} finally { } 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 (
<AuthShell title={tl.otpTitle} subtitle={tl.otpSubtitle}>
<OtpStep
destination={otpStep}
submitting={isLoading}
onVerify={handleVerify}
onResend={() => requestOtp(phone)}
onBack={() => setOtpStep(null)}
/>
</AuthShell>
);
}
return ( return (
<AuthShell <AuthShell
title={tl.title} title={tl.title}
@@ -39,19 +97,37 @@ export default function LoginPage() {
altHref="/register" altHref="/register"
altLabel={tl.altLabel} altLabel={tl.altLabel}
> >
<form className="space-y-5" onSubmit={handleSubmit}> {/* Method tabs */}
<div className="mb-5 grid grid-cols-2 gap-1 rounded-xl border border-white/15 bg-white/5 p-1">
{(['password', 'otp'] as Method[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMethod(m)}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${
method === m
? 'bg-primary-600 text-white shadow'
: 'text-white/70 hover:text-white'
}`}
>
{m === 'password' ? tl.tabPassword : tl.tabOtp}
</button>
))}
</div>
{method === 'password' ? (
<form className="space-y-5" onSubmit={handlePasswordLogin}>
<AuthField <AuthField
id="email" id="phone"
label={tl.email} label={tl.phone}
type="email" type="tel"
required required
dir="ltr" dir="ltr"
autoComplete="email" autoComplete="tel"
placeholder="you@example.com" placeholder="09123456789"
value={email} value={phone}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setPhone(e.target.value)}
/> />
<AuthField <AuthField
id="password" id="password"
label={tl.password} label={tl.password}
@@ -63,25 +139,55 @@ export default function LoginPage() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<SubmitButton loading={isLoading} label={tl.submit} loadingLabel={tl.submitting} />
</form>
) : (
<form className="space-y-5" onSubmit={handleOtpRequest}>
<AuthField
id="phone"
label={tl.phone}
type="tel"
required
dir="ltr"
autoComplete="tel"
placeholder="09123456789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<p className="text-xs text-white/55">{tl.otpHint}</p>
<SubmitButton loading={isLoading} label={tl.sendCode} loadingLabel={tl.sending} />
</form>
)}
</AuthShell>
);
}
function SubmitButton({
loading,
label,
loadingLabel,
}: {
loading: boolean;
label: string;
loadingLabel: string;
}) {
return (
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={loading}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-60"
> >
{isLoading ? ( {loading ? (
<> <>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{tl.submitting} {loadingLabel}
</> </>
) : ( ) : (
<> <>
{tl.submit} {label}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" /> <ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</> </>
)} )}
</button> </button>
</form>
</AuthShell>
); );
} }
+57 -7
View File
@@ -6,23 +6,35 @@ import { notify } from '@/lib/notify';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { AuthShell } from '@/components/auth/AuthShell'; import { AuthShell } from '@/components/auth/AuthShell';
import { AuthField } from '@/components/auth/AuthField'; import { AuthField } from '@/components/auth/AuthField';
import { OtpStep } from '@/components/auth/OtpStep';
import { useT } from '@/i18n/I18nProvider'; import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation'; import { useLocalizedRouter } from '@/i18n/navigation';
export default function RegisterPage() { export default function RegisterPage() {
const tr = useT().auth.register; 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 [isLoading, setIsLoading] = useState(false);
const [otpStep, setOtpStep] = useState<string | null>(null);
const register = useAuthStore((s) => s.register); const register = useAuthStore((s) => s.register);
const requestOtp = useAuthStore((s) => s.requestOtp);
const verifyOtp = useAuthStore((s) => s.verifyOtp);
const router = useLocalizedRouter(); const router = useLocalizedRouter();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
try { try {
await register(form); const { phone } = await register({
notify.success(tr.success); firstName: form.firstName,
router.push('/dashboard'); 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) { } catch (err: any) {
notify.error(err, tr.error); notify.error(err, tr.error);
} finally { } 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 (
<AuthShell title={tl.otpTitle} subtitle={tl.otpSubtitle}>
<OtpStep
destination={otpStep}
submitting={isLoading}
onVerify={handleVerify}
onResend={() => requestOtp(form.phone.trim())}
onBack={() => setOtpStep(null)}
/>
</AuthShell>
);
}
return ( return (
<AuthShell <AuthShell
title={tr.title} title={tr.title}
@@ -63,11 +102,22 @@ export default function RegisterPage() {
</div> </div>
<AuthField <AuthField
id="email" id="phone"
label={tr.email} label={tr.phone}
type="email" type="tel"
required required
dir="ltr" dir="ltr"
autoComplete="tel"
placeholder="09123456789"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
<AuthField
id="email"
label={tr.emailOptional}
type="email"
dir="ltr"
autoComplete="email" autoComplete="email"
placeholder="you@example.com" placeholder="you@example.com"
value={form.email} value={form.email}
+6 -1
View File
@@ -1,9 +1,11 @@
import type { InputHTMLAttributes } from 'react'; import type { InputHTMLAttributes } from 'react';
import clsx from 'clsx';
// A labeled input styled for the frosted dark-glass auth panel. // A labeled input styled for the frosted dark-glass auth panel.
export function AuthField({ export function AuthField({
label, label,
id, id,
className,
...props ...props
}: { label: string } & InputHTMLAttributes<HTMLInputElement>) { }: { label: string } & InputHTMLAttributes<HTMLInputElement>) {
return ( return (
@@ -14,7 +16,10 @@ export function AuthField({
<input <input
id={id} id={id}
{...props} {...props}
className="w-full rounded-xl border border-white/15 bg-white/10 px-4 py-3 text-white placeholder-white/45 outline-none backdrop-blur-md transition focus:border-primary-400 focus:bg-white/[0.16] focus:ring-2 focus:ring-primary-400/30" className={clsx(
'w-full rounded-xl border border-white/15 bg-white/10 px-4 py-3 text-white placeholder-white/45 outline-none backdrop-blur-md transition focus:border-primary-400 focus:bg-white/[0.16] focus:ring-2 focus:ring-primary-400/30',
className,
)}
/> />
</div> </div>
); );
+5 -3
View File
@@ -21,9 +21,9 @@ export function AuthShell({
title: string; title: string;
subtitle: string; subtitle: string;
children: ReactNode; children: ReactNode;
altPrompt: string; altPrompt?: string;
altHref: string; altHref?: string;
altLabel: string; altLabel?: string;
}) { }) {
const t = useT(); const t = useT();
return ( return (
@@ -69,12 +69,14 @@ export function AuthShell({
<div className="abrban-panel rounded-[1.75rem] p-6 sm:p-8">{children}</div> <div className="abrban-panel rounded-[1.75rem] p-6 sm:p-8">{children}</div>
{altPrompt && altHref && altLabel && (
<p className="abrban-ink mt-6 text-center text-sm text-white/90"> <p className="abrban-ink mt-6 text-center text-sm text-white/90">
{altPrompt}{' '} {altPrompt}{' '}
<Link href={altHref} className="font-bold text-primary-200 underline-offset-4 hover:text-white hover:underline"> <Link href={altHref} className="font-bold text-primary-200 underline-offset-4 hover:text-white hover:underline">
{altLabel} {altLabel}
</Link> </Link>
</p> </p>
)}
</div> </div>
</main> </main>
</div> </div>
+111
View File
@@ -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<void>;
onResend: () => void | Promise<void>;
onBack: () => void;
submitting: boolean;
}) {
const t = useT().auth.otp;
const [code, setCode] = useState('');
const [cooldown, setCooldown] = useState(60);
const timer = useRef<ReturnType<typeof setInterval> | 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 (
<form className="space-y-5" onSubmit={submit}>
<p className="text-sm text-white/70 leading-relaxed">
{t.sentTo}{' '}
<span dir="ltr" className="font-mono font-semibold text-white">
{destination}
</span>
</p>
<AuthField
id="otp"
label={t.codeLabel}
type="text"
required
dir="ltr"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="------"
className="text-center tracking-[0.5em] font-mono"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
/>
<button
type="submit"
disabled={submitting || code.length < 4}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{t.verifying}
</>
) : (
<>
{t.verify}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</>
)}
</button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={onBack}
className="text-white/60 transition hover:text-white"
>
{t.back}
</button>
<button
type="button"
onClick={resend}
disabled={cooldown > 0}
className="text-primary-300 transition hover:text-primary-200 disabled:cursor-not-allowed disabled:opacity-50"
>
{cooldown > 0 ? t.resendIn.replace('{s}', String(cooldown)) : t.resend}
</button>
</div>
</form>
);
}
+71 -4
View File
@@ -120,11 +120,20 @@ const en: Dictionary = {
backHome: 'Back to home', backHome: 'Back to home',
login: { login: {
title: 'Welcome back', title: 'Welcome back',
subtitle: 'Sign in to your Abrban account', subtitle: 'Sign in to your Abrban account with your mobile',
altPrompt: 'Dont have an account yet?', altPrompt: 'Dont have an account yet?',
altLabel: 'Sign up', altLabel: 'Sign up',
email: 'Email', tabPassword: 'With password',
tabOtp: 'With one-time code',
phone: 'Mobile number',
password: 'Password', 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', submit: 'Sign in',
submitting: 'Signing in…', submitting: 'Signing in…',
success: 'Signed in successfully!', success: 'Signed in successfully!',
@@ -139,13 +148,25 @@ const en: Dictionary = {
firstNamePlaceholder: 'e.g. Ali', firstNamePlaceholder: 'e.g. Ali',
lastName: 'Last name', lastName: 'Last name',
lastNamePlaceholder: 'e.g. Rezaei', lastNamePlaceholder: 'e.g. Rezaei',
email: 'Email', phone: 'Mobile number',
emailOptional: 'Email (optional)',
password: 'Password', password: 'Password',
passwordPlaceholder: 'At least 8 characters', passwordPlaceholder: 'At least 8 characters',
submit: 'Create account', submit: 'Create account',
submitting: 'Creating 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', 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', wallet: 'Wallet',
invoices: 'Invoices', invoices: 'Invoices',
tickets: 'Tickets', tickets: 'Tickets',
account: 'My Account',
users: 'Users', users: 'Users',
allApplications: 'All Applications', allApplications: 'All Applications',
billingPlans: 'Billing Plans', billingPlans: 'Billing Plans',
@@ -402,6 +424,48 @@ const en: Dictionary = {
}, },
dashboard: { 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: { status: {
running: 'Running', running: 'Running',
pending: 'Pending', 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.', 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…', checkingConnection: 'Checking connection…',
retryingAuto: 'Retrying automatically every few seconds.', 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: { users: {
title: 'User Management', title: 'User Management',
+71 -4
View File
@@ -119,11 +119,20 @@ const fa = {
backHome: 'بازگشت به خانه', backHome: 'بازگشت به خانه',
login: { login: {
title: 'خوش آمدی', title: 'خوش آمدی',
subtitle: ه حساب ابربان خود وارد شو', subtitle: ا شماره موبایل وارد حساب ابربان شو',
altPrompt: 'هنوز حساب نداری؟', altPrompt: 'هنوز حساب نداری؟',
altLabel: 'ثبت‌نام کن', altLabel: 'ثبت‌نام کن',
email: 'ایمیل', tabPassword: 'با رمز عبور',
tabOtp: 'با رمز یکبارمصرف',
phone: 'شماره موبایل',
password: 'رمز عبور', password: 'رمز عبور',
otpHint: 'یک کد تأیید به شماره موبایلت پیامک می‌شود.',
sendCode: 'ارسال کد',
sending: 'در حال ارسال…',
codeSent: 'کد تأیید پیامک شد',
verifyNeeded: 'برای ادامه، شماره موبایلت را تأیید کن.',
otpTitle: 'تأیید شماره موبایل',
otpSubtitle: 'کد ۶ رقمی پیامک‌شده را وارد کن',
submit: 'ورود', submit: 'ورود',
submitting: 'در حال ورود…', submitting: 'در حال ورود…',
success: 'با موفقیت وارد شدی!', success: 'با موفقیت وارد شدی!',
@@ -138,13 +147,25 @@ const fa = {
firstNamePlaceholder: 'مثلاً علی', firstNamePlaceholder: 'مثلاً علی',
lastName: 'نام خانوادگی', lastName: 'نام خانوادگی',
lastNamePlaceholder: 'مثلاً رضایی', lastNamePlaceholder: 'مثلاً رضایی',
email: 'ایمیل', phone: 'شماره موبایل',
emailOptional: 'ایمیل (اختیاری)',
password: 'رمز عبور', password: 'رمز عبور',
passwordPlaceholder: 'حداقل ۸ کاراکتر', passwordPlaceholder: 'حداقل ۸ کاراکتر',
submit: 'ساخت حساب', submit: 'ساخت حساب',
submitting: 'در حال ساخت حساب…', submitting: 'در حال ساخت حساب…',
success: 'حساب با موفقیت ساخته شد!', codeSent: 'کد تأیید به موبایلت پیامک شد',
success: 'حساب با موفقیت ساخته و تأیید شد!',
error: 'ثبت‌نام ناموفق بود', error: 'ثبت‌نام ناموفق بود',
verifyError: 'تأیید کد ناموفق بود',
},
otp: {
sentTo: 'کد تأیید به این شماره پیامک شد:',
codeLabel: 'کد تأیید',
verify: 'تأیید و ادامه',
verifying: 'در حال بررسی…',
back: 'بازگشت',
resend: 'ارسال مجدد کد',
resendIn: 'ارسال مجدد تا {s} ثانیه',
}, },
}, },
@@ -390,6 +411,7 @@ const fa = {
wallet: 'کیف‌پول', wallet: 'کیف‌پول',
invoices: 'فاکتورها', invoices: 'فاکتورها',
tickets: 'تیکت‌ها', tickets: 'تیکت‌ها',
account: 'حساب من',
users: 'کاربران', users: 'کاربران',
allApplications: 'همهٔ اپلیکیشن‌ها', allApplications: 'همهٔ اپلیکیشن‌ها',
billingPlans: 'پلن‌های صورت‌حساب', billingPlans: 'پلن‌های صورت‌حساب',
@@ -401,6 +423,48 @@ const fa = {
}, },
dashboard: { 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: { status: {
running: 'در حال اجرا', running: 'در حال اجرا',
pending: 'در انتظار', pending: 'در انتظار',
@@ -678,6 +742,9 @@ const fa = {
notAvailableMessage: 'Elasticsearch مرکزی روی کلاستر مستقر نشده است. هنگام انتشار اپ، افزونهٔ لاگینگ را فعال کن و از مدیر بخواه استک لاگینگ را مستقر کند.', notAvailableMessage: 'Elasticsearch مرکزی روی کلاستر مستقر نشده است. هنگام انتشار اپ، افزونهٔ لاگینگ را فعال کن و از مدیر بخواه استک لاگینگ را مستقر کند.',
checkingConnection: 'در حال بررسی اتصال…', checkingConnection: 'در حال بررسی اتصال…',
retryingAuto: 'هر چند ثانیه به‌صورت خودکار تلاش می‌شود.', retryingAuto: 'هر چند ثانیه به‌صورت خودکار تلاش می‌شود.',
elasticDisabledTitle: 'لاگ‌گیری Elasticsearch فعال نیست',
elasticDisabledMessage: 'برای این اپلیکیشن هنگام ساخت، گزینهٔ Elasticsearch را فعال نکرده‌ای؛ به همین دلیل لاگ متمرکز در دسترس نیست. هنگام انتشار یک اپ جدید افزونهٔ Elasticsearch را فعال کن تا لاگ‌های آن اینجا نمایش داده شود.',
elasticDisabledMessageAll: 'هیچ‌کدام از اپلیکیشن‌هایت هنگام ساخت، Elasticsearch را فعال نکرده‌اند؛ به همین دلیل لاگ متمرکز در دسترس نیست. هنگام انتشار یک اپ جدید افزونهٔ Elasticsearch را فعال کن تا لاگ‌های آن اینجا نمایش داده شود.',
}, },
users: { users: {
title: 'مدیریت کاربران', title: 'مدیریت کاربران',
+55 -9
View File
@@ -4,14 +4,44 @@ import { create } from 'zustand';
import api from '@/lib/api'; import api from '@/lib/api';
import type { User, AuthResponse } from '@/types'; 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 { interface AuthState {
user: User | null; user: User | null;
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>; /** Password login by mobile. May require OTP verification. */
register: (data: { email: string; password: string; firstName: string; lastName: string }) => Promise<void>; login: (phone: string, password: string) => Promise<AuthResult>;
/** 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<void>;
/** Verify a one-time code (completes registration or OTP login). */
verifyOtp: (phone: string, code: string) => Promise<void>;
logout: () => void; logout: () => void;
loadUser: () => Promise<void>; loadUser: () => Promise<void>;
setUser: (user: User) => void;
}
function persistAuth(data: AuthResponse) {
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
@@ -19,17 +49,31 @@ export const useAuthStore = create<AuthState>((set) => ({
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
login: async (email, password) => { login: async (phone, password) => {
const { data } = await api.post<AuthResponse>('/auth/login', { email, password }); const { data } = await api.post<AuthResponse | VerificationRequired>(
localStorage.setItem('accessToken', data.accessToken); '/auth/login',
localStorage.setItem('refreshToken', data.refreshToken); { phone, password },
);
if ('requiresVerification' in data) {
return { status: 'verify', phone: data.phone };
}
persistAuth(data);
set({ user: data.user, isAuthenticated: true }); set({ user: data.user, isAuthenticated: true });
return { status: 'authenticated' };
}, },
register: async (registerData) => { register: async (registerData) => {
const { data } = await api.post<AuthResponse>('/auth/register', registerData); const { data } = await api.post<VerificationRequired>('/auth/register', registerData);
localStorage.setItem('accessToken', data.accessToken); return { phone: data.phone };
localStorage.setItem('refreshToken', data.refreshToken); },
requestOtp: async (phone) => {
await api.post('/auth/otp/request', { phone });
},
verifyOtp: async (phone, code) => {
const { data } = await api.post<AuthResponse>('/auth/otp/verify', { phone, code });
persistAuth(data);
set({ user: data.user, isAuthenticated: true }); set({ user: data.user, isAuthenticated: true });
}, },
@@ -52,4 +96,6 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ user: null, isAuthenticated: false, isLoading: false }); set({ user: null, isAuthenticated: false, isLoading: false });
} }
}, },
setUser: (user) => set({ user }),
})); }));
+3 -1
View File
@@ -1,6 +1,8 @@
export interface User { export interface User {
id: string; id: string;
email: string; phone?: string | null;
email: string | null;
phoneVerified?: boolean;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: 'user' | 'admin' | 'technical' | 'sales'; role: 'user' | 'admin' | 'technical' | 'sales';