Redesign login/register with Mento branding and email/mobile OTP.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "mobile" TEXT;
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "emailVerifiedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "mobileVerifiedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "User_mobile_key" ON "User"("mobile");
|
||||
CREATE INDEX IF NOT EXISTS "User_email_idx" ON "User"("email");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "AuthChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"channel" TEXT,
|
||||
"email" TEXT,
|
||||
"mobile" TEXT,
|
||||
"passwordHash" TEXT,
|
||||
"payload" JSONB NOT NULL DEFAULT '{}',
|
||||
"emailCode" TEXT,
|
||||
"mobileCode" TEXT,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"mobileVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuthChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "AuthChallenge_kind_createdAt_idx" ON "AuthChallenge"("kind", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "AuthChallenge_expiresAt_idx" ON "AuthChallenge"("expiresAt");
|
||||
CREATE INDEX IF NOT EXISTS "AuthChallenge_email_idx" ON "AuthChallenge"("email");
|
||||
CREATE INDEX IF NOT EXISTS "AuthChallenge_mobile_idx" ON "AuthChallenge"("mobile");
|
||||
@@ -172,9 +172,13 @@ model Organization {
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
/// موبایل نرمالشده (مثلاً 9121234567) — برای ورود با موبایل
|
||||
mobile String?
|
||||
passwordHash String
|
||||
fullName String
|
||||
role UserRole @default(sales)
|
||||
emailVerifiedAt DateTime?
|
||||
mobileVerifiedAt DateTime?
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
ownedDeals Deal[] @relation("DealOwner")
|
||||
@@ -197,7 +201,9 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, email])
|
||||
@@unique([mobile])
|
||||
@@index([organizationId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model Company {
|
||||
@@ -934,6 +940,32 @@ model AutomationRule {
|
||||
@@index([organizationId, isActive, trigger])
|
||||
}
|
||||
|
||||
/// چالش OTP برای ثبتنام / ورود پنل
|
||||
model AuthChallenge {
|
||||
id String @id @default(cuid())
|
||||
/// register | login
|
||||
kind String
|
||||
/// برای login: email | mobile
|
||||
channel String?
|
||||
email String?
|
||||
mobile String?
|
||||
passwordHash String?
|
||||
/// دادهٔ موقت ثبتنام (سازمان، نام، زمینه، کد معرف، …)
|
||||
payload Json @default("{}")
|
||||
emailCode String?
|
||||
mobileCode String?
|
||||
emailVerified Boolean @default(false)
|
||||
mobileVerified Boolean @default(false)
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([kind, createdAt])
|
||||
@@index([expiresAt])
|
||||
@@index([email])
|
||||
@@index([mobile])
|
||||
}
|
||||
|
||||
/// اتصال به نرمافزار حسابداری خارجی (تهحساب) — چند اتصال/DB بهازای هر سازمان
|
||||
model AccountingConnection {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -38,9 +38,12 @@ async function main() {
|
||||
users: {
|
||||
create: {
|
||||
email,
|
||||
mobile: '09120000001',
|
||||
fullName: 'مدیر نمونه',
|
||||
passwordHash,
|
||||
role: 'owner',
|
||||
emailVerifiedAt: new Date(),
|
||||
mobileVerifiedAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -54,6 +57,17 @@ async function main() {
|
||||
const owner = await prisma.user.findFirst({
|
||||
where: { organizationId: org.id, email },
|
||||
});
|
||||
if (owner && !owner.mobile) {
|
||||
await prisma.user.update({
|
||||
where: { id: owner.id },
|
||||
data: {
|
||||
mobile: '09120000001',
|
||||
emailVerifiedAt: owner.emailVerifiedAt ?? new Date(),
|
||||
mobileVerifiedAt: owner.mobileVerifiedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
console.log('Updated demo owner mobile: 09120000001');
|
||||
}
|
||||
|
||||
// چندارزی نمونه
|
||||
await prisma.organization.update({
|
||||
@@ -93,9 +107,12 @@ async function main() {
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
email: salesEmail,
|
||||
mobile: '09120000002',
|
||||
fullName: 'کارشناس فروش نمونه',
|
||||
passwordHash: await bcrypt.hash('demo1234', 10),
|
||||
role: 'sales',
|
||||
emailVerifiedAt: new Date(),
|
||||
mobileVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log('Seeded sales user:', salesEmail, '/ demo1234');
|
||||
@@ -110,9 +127,12 @@ async function main() {
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
email: adminEmail,
|
||||
mobile: '09120000003',
|
||||
fullName: 'مدیر سازمانی نمونه',
|
||||
passwordHash: await bcrypt.hash('demo1234', 10),
|
||||
role: 'admin',
|
||||
emailVerifiedAt: new Date(),
|
||||
mobileVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log('Seeded admin user:', adminEmail, '/ demo1234');
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, RegisterOrganizationDto } from './dto';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginVerifyOtpDto,
|
||||
RegisterOrganizationDto,
|
||||
RegisterVerifyOtpDto,
|
||||
} from './dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
|
||||
@@ -10,12 +15,22 @@ export class AuthController {
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterOrganizationDto) {
|
||||
return this.authService.registerOrganization(dto);
|
||||
return this.authService.startRegister(dto);
|
||||
}
|
||||
|
||||
@Post('register/verify')
|
||||
registerVerify(@Body() dto: RegisterVerifyOtpDto) {
|
||||
return this.authService.verifyRegister(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
return this.authService.loginLegacy(dto);
|
||||
}
|
||||
|
||||
@Post('login/verify')
|
||||
loginVerify(@Body() dto: LoginVerifyOtpDto) {
|
||||
return this.authService.verifyLogin(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
@@ -22,7 +24,7 @@ import { RolesGuard } from './roles.guard';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, RolesGuard],
|
||||
providers: [AuthService, JwtStrategy, RolesGuard, SmsService],
|
||||
exports: [AuthService, RolesGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,83 +1,357 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import type { AuthUserDto, LoginResponse } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto, RegisterOrganizationDto } from './dto';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginVerifyOtpDto,
|
||||
RegisterOrganizationDto,
|
||||
RegisterVerifyOtpDto,
|
||||
} from './dto';
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
const OTP_TTL_SECONDS = 5 * 60;
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u0600-\u06FF]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || `org-${Date.now()}`;
|
||||
return (
|
||||
input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u0600-\u06FF]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || `org-${Date.now()}`
|
||||
);
|
||||
}
|
||||
|
||||
/** نرمالسازی موبایل ایران به 09xxxxxxxxx */
|
||||
export function normalizeMobile(raw: string): string | null {
|
||||
let s = raw.replace(/[\s\-()]/g, '');
|
||||
if (s.startsWith('+98')) s = `0${s.slice(3)}`;
|
||||
if (s.startsWith('98') && s.length === 12) s = `0${s.slice(2)}`;
|
||||
if (/^9\d{9}$/.test(s)) s = `0${s}`;
|
||||
if (/^09\d{9}$/.test(s)) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeEmail(value: string): boolean {
|
||||
return value.includes('@');
|
||||
}
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@');
|
||||
if (!domain) return '***';
|
||||
const head = local.slice(0, Math.min(2, local.length));
|
||||
return `${head}***@${domain}`;
|
||||
}
|
||||
|
||||
function maskMobile(mobile: string): string {
|
||||
if (mobile.length < 4) return '****';
|
||||
return `${mobile.slice(0, 4)}***${mobile.slice(-2)}`;
|
||||
}
|
||||
|
||||
function generateOtp(): string {
|
||||
return String(
|
||||
Math.floor(10 ** (OTP_LENGTH - 1) + Math.random() * 9 * 10 ** (OTP_LENGTH - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
type RegisterPayload = {
|
||||
organizationName: string;
|
||||
fullName: string;
|
||||
businessField: string;
|
||||
referralCode?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async registerOrganization(dto: RegisterOrganizationDto): Promise<LoginResponse> {
|
||||
const baseSlug = slugify(dto.organizationName);
|
||||
/** مرحله ۱ ثبتنام: اعتبارسنجی + ارسال OTP ایمیل و موبایل */
|
||||
async startRegister(dto: RegisterOrganizationDto) {
|
||||
if (dto.password !== dto.passwordConfirm) {
|
||||
throw new BadRequestException('تکرار رمز عبور مطابقت ندارد');
|
||||
}
|
||||
if (dto.acceptedTerms === false) {
|
||||
throw new BadRequestException('پذیرش شرایط و قوانین الزامی است');
|
||||
}
|
||||
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const mobile = normalizeMobile(dto.mobile);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const existingEmail = await this.prisma.user.findFirst({ where: { email } });
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||
}
|
||||
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
if (existingMobile) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
await this.throttleChallenges('register', email, mobile);
|
||||
|
||||
const emailCode = generateOtp();
|
||||
const mobileCode = generateOtp();
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const payload: RegisterPayload = {
|
||||
organizationName: dto.organizationName.trim(),
|
||||
fullName: dto.fullName.trim(),
|
||||
businessField: dto.businessField,
|
||||
...(dto.referralCode?.trim()
|
||||
? { referralCode: dto.referralCode.trim() }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const challenge = await this.prisma.authChallenge.create({
|
||||
data: {
|
||||
kind: 'register',
|
||||
email,
|
||||
mobile,
|
||||
passwordHash,
|
||||
payload: payload as Prisma.InputJsonValue,
|
||||
emailCode,
|
||||
mobileCode,
|
||||
expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const returnOtp = this.shouldReturnOtp();
|
||||
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
||||
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
|
||||
|
||||
return {
|
||||
challengeId: challenge.id,
|
||||
expiresInSeconds: OTP_TTL_SECONDS,
|
||||
maskedEmail: maskEmail(email),
|
||||
maskedMobile: maskMobile(mobile),
|
||||
...(returnOtp
|
||||
? { devEmailCode: emailCode, devMobileCode: mobileCode }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** مرحله ۲ ثبتنام: تأیید هر دو کد و ساخت سازمان */
|
||||
async verifyRegister(dto: RegisterVerifyOtpDto): Promise<LoginResponse> {
|
||||
const challenge = await this.prisma.authChallenge.findUnique({
|
||||
where: { id: dto.challengeId },
|
||||
});
|
||||
if (
|
||||
!challenge ||
|
||||
challenge.kind !== 'register' ||
|
||||
challenge.consumedAt ||
|
||||
challenge.expiresAt <= new Date()
|
||||
) {
|
||||
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
||||
}
|
||||
|
||||
const emailOk = challenge.emailCode === dto.emailCode.trim();
|
||||
const mobileOk = challenge.mobileCode === dto.mobileCode.trim();
|
||||
if (!emailOk || !mobileOk) {
|
||||
throw new UnauthorizedException('کد تأیید ایمیل یا موبایل نادرست است');
|
||||
}
|
||||
|
||||
const email = (challenge.email ?? '').toLowerCase();
|
||||
const mobile = challenge.mobile ?? '';
|
||||
if (!email || !mobile || !challenge.passwordHash) {
|
||||
throw new BadRequestException('چالش ثبتنام ناقص است');
|
||||
}
|
||||
|
||||
const payload = (challenge.payload ?? {}) as RegisterPayload;
|
||||
if (!payload.organizationName || !payload.fullName || !payload.businessField) {
|
||||
throw new BadRequestException('اطلاعات ثبتنام ناقص است');
|
||||
}
|
||||
|
||||
const existingEmail = await this.prisma.user.findFirst({ where: { email } });
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||
}
|
||||
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
if (existingMobile) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const baseSlug = slugify(payload.organizationName);
|
||||
let slug = baseSlug;
|
||||
let i = 1;
|
||||
while (await this.prisma.organization.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${i++}`;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email.toLowerCase() },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const now = new Date();
|
||||
const organization = await this.prisma.organization.create({
|
||||
data: {
|
||||
name: dto.organizationName,
|
||||
name: payload.organizationName,
|
||||
slug,
|
||||
businessField: dto.businessField,
|
||||
businessField: payload.businessField,
|
||||
users: {
|
||||
create: {
|
||||
email: dto.email.toLowerCase(),
|
||||
fullName: dto.fullName,
|
||||
passwordHash,
|
||||
email,
|
||||
mobile,
|
||||
fullName: payload.fullName,
|
||||
passwordHash: challenge.passwordHash,
|
||||
role: 'owner',
|
||||
emailVerifiedAt: now,
|
||||
mobileVerifiedAt: now,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { users: true },
|
||||
});
|
||||
|
||||
const user = organization.users[0];
|
||||
return this.buildLoginResponse(user);
|
||||
await this.prisma.authChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
data: {
|
||||
consumedAt: now,
|
||||
emailVerified: true,
|
||||
mobileVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.buildLoginResponse(organization.users[0]);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<LoginResponse> {
|
||||
/** مرحله ۱ ورود: ایمیل/موبایل + رمز → ارسال OTP روی همان کانال */
|
||||
async startLogin(dto: LoginDto) {
|
||||
const identifier = (dto.identifier ?? dto.email ?? '').trim();
|
||||
const isEmail = looksLikeEmail(identifier);
|
||||
const email = isEmail ? identifier.toLowerCase() : null;
|
||||
const mobile = isEmail ? null : normalizeMobile(identifier);
|
||||
|
||||
if (!identifier || (!email && !mobile)) {
|
||||
throw new BadRequestException('ایمیل یا شماره موبایل معتبر وارد کنید');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email.toLowerCase() },
|
||||
where: email ? { email } : { mobile: mobile! },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
|
||||
const channel = email ? 'email' : 'mobile';
|
||||
if (channel === 'mobile' && !user.mobile) {
|
||||
throw new BadRequestException('موبایل برای این حساب ثبت نشده است');
|
||||
}
|
||||
if (channel === 'email' && !user.email) {
|
||||
throw new BadRequestException('ایمیل برای این حساب ثبت نشده است');
|
||||
}
|
||||
|
||||
await this.throttleChallenges('login', user.email, user.mobile);
|
||||
|
||||
const code = generateOtp();
|
||||
const challenge = await this.prisma.authChallenge.create({
|
||||
data: {
|
||||
kind: 'login',
|
||||
channel,
|
||||
email: user.email,
|
||||
mobile: user.mobile,
|
||||
passwordHash: null,
|
||||
payload: { userId: user.id } as Prisma.InputJsonValue,
|
||||
emailCode: channel === 'email' ? code : null,
|
||||
mobileCode: channel === 'mobile' ? code : null,
|
||||
expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const returnOtp = this.shouldReturnOtp();
|
||||
if (channel === 'email') {
|
||||
await this.dispatchEmailOtp(user.email, code, returnOtp);
|
||||
} else {
|
||||
await this.dispatchMobileOtp(user.mobile!, code, returnOtp);
|
||||
}
|
||||
|
||||
return {
|
||||
challengeId: challenge.id,
|
||||
channel: channel as 'email' | 'mobile',
|
||||
expiresInSeconds: OTP_TTL_SECONDS,
|
||||
maskedDestination:
|
||||
channel === 'email' ? maskEmail(user.email) : maskMobile(user.mobile!),
|
||||
...(returnOtp ? { devCode: code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** مرحله ۲ ورود: تأیید OTP */
|
||||
async verifyLogin(dto: LoginVerifyOtpDto): Promise<LoginResponse> {
|
||||
const challenge = await this.prisma.authChallenge.findUnique({
|
||||
where: { id: dto.challengeId },
|
||||
});
|
||||
if (
|
||||
!challenge ||
|
||||
challenge.kind !== 'login' ||
|
||||
challenge.consumedAt ||
|
||||
challenge.expiresAt <= new Date()
|
||||
) {
|
||||
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
||||
}
|
||||
|
||||
const code = dto.code.trim();
|
||||
const expected =
|
||||
challenge.channel === 'email' ? challenge.emailCode : challenge.mobileCode;
|
||||
if (!expected || expected !== code) {
|
||||
throw new UnauthorizedException('کد تأیید نادرست است');
|
||||
}
|
||||
|
||||
const payload = (challenge.payload ?? {}) as { userId?: string };
|
||||
const userId = payload.userId;
|
||||
if (!userId) {
|
||||
throw new BadRequestException('چالش ورود ناقص است');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.authChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
data: {
|
||||
consumedAt: now,
|
||||
emailVerified: challenge.channel === 'email',
|
||||
mobileVerified: challenge.channel === 'mobile',
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data:
|
||||
challenge.channel === 'email'
|
||||
? { emailVerifiedAt: user.emailVerifiedAt ?? now }
|
||||
: { mobileVerifiedAt: user.mobileVerifiedAt ?? now },
|
||||
});
|
||||
|
||||
return this.buildLoginResponse(user);
|
||||
}
|
||||
|
||||
/** سازگاری موقت: اگر کلاینت قدیمی email بفرستد */
|
||||
async loginLegacy(body: LoginDto) {
|
||||
return this.startLogin(body);
|
||||
}
|
||||
|
||||
async me(userId: string): Promise<AuthUserDto> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
@@ -86,9 +360,80 @@ export class AuthService {
|
||||
return this.toAuthUser(user);
|
||||
}
|
||||
|
||||
private async throttleChallenges(
|
||||
kind: string,
|
||||
email: string | null | undefined,
|
||||
mobile: string | null | undefined,
|
||||
) {
|
||||
const since = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const count = await this.prisma.authChallenge.count({
|
||||
where: {
|
||||
kind,
|
||||
createdAt: { gte: since },
|
||||
OR: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(mobile ? [{ mobile }] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
if (count >= 12) {
|
||||
throw new BadRequestException(
|
||||
'درخواست زیاد است؛ کمی بعد دوباره تلاش کنید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldReturnOtp(): boolean {
|
||||
return (
|
||||
this.config.get<string>('AUTH_RETURN_OTP') === 'true' ||
|
||||
this.config.get<string>('PORTAL_SHOW_OTP') === 'true' ||
|
||||
(this.config.get<string>('NODE_ENV') !== 'production' &&
|
||||
this.config.get<string>('AUTH_RETURN_OTP') !== 'false')
|
||||
);
|
||||
}
|
||||
|
||||
private async dispatchEmailOtp(
|
||||
email: string,
|
||||
code: string,
|
||||
returnOtp: boolean,
|
||||
) {
|
||||
// فعلاً سرویس ایمیل پیکربندی نشده؛ در محیط غیرپروداکشن کد لاگ میشود
|
||||
this.logger.log(`[auth-otp-email] ${email} => ${code}`);
|
||||
if (!returnOtp && this.config.get<string>('NODE_ENV') === 'production') {
|
||||
// بدون ایمیل واقعی در پروداکشن، ورود/ثبتنام با ایمیل ممکن نیست مگر AUTH_RETURN_OTP
|
||||
throw new BadRequestException(
|
||||
'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchMobileOtp(
|
||||
mobile: string,
|
||||
code: string,
|
||||
returnOtp: boolean,
|
||||
) {
|
||||
if (this.sms.isConfigured()) {
|
||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
||||
if (!sent && !returnOtp) {
|
||||
throw new BadRequestException(
|
||||
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
||||
);
|
||||
}
|
||||
if (!sent) {
|
||||
this.logger.warn(`[auth-otp-sms-fallback] ${mobile} => ${code}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!returnOtp) {
|
||||
throw new BadRequestException('سامانه پیامک پیکربندی نشده است');
|
||||
}
|
||||
this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`);
|
||||
}
|
||||
|
||||
private buildLoginResponse(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
role: AuthUserDto['role'];
|
||||
organizationId: string;
|
||||
@@ -107,6 +452,7 @@ export class AuthService {
|
||||
private toAuthUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
role: AuthUserDto['role'];
|
||||
organizationId: string;
|
||||
@@ -117,6 +463,7 @@ export class AuthService {
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { BUSINESS_FIELDS } from '@mento/shared';
|
||||
|
||||
export class LoginDto {
|
||||
/** ایمیل یا موبایل — کلاینت جدید */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
identifier?: string;
|
||||
|
||||
/** سازگاری با کلاینت قدیمی */
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class LoginVerifyOtpDto {
|
||||
@IsString()
|
||||
challengeId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class RegisterOrganizationDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@@ -27,10 +47,41 @@ export class RegisterOrganizationDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
|
||||
message: 'شماره موبایل معتبر نیست',
|
||||
})
|
||||
mobile!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
passwordConfirm!: string;
|
||||
|
||||
@IsIn([...BUSINESS_FIELDS])
|
||||
businessField!: (typeof BUSINESS_FIELDS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
referralCode?: string;
|
||||
|
||||
@ValidateIf((_, v) => v !== undefined)
|
||||
@IsOptional()
|
||||
acceptedTerms?: boolean;
|
||||
}
|
||||
|
||||
export class RegisterVerifyOtpDto {
|
||||
@IsString()
|
||||
challengeId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
emailCode!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
mobileCode!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
headline?: string;
|
||||
description?: string;
|
||||
}>(),
|
||||
{
|
||||
headline: 'همه ارتباطات، یکجا مدیریت میشوند',
|
||||
description:
|
||||
'با منتو فروش، پشتیبانی و ارتباط با مشتری را در یک پنل یکپارچه مدیریت کنید و رشد پایدار بسازید.',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="brand-panel" aria-hidden="false">
|
||||
<div class="brand-head">
|
||||
<MentoLogo :size="44" :show-text="false" />
|
||||
<div class="brand-copy">
|
||||
<strong>منتو</strong>
|
||||
<span>نرمافزار مدیریت ارتباط با مشتری</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-body">
|
||||
<h2>{{ headline }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="illustration" aria-hidden="true">
|
||||
<div class="dash">
|
||||
<div class="dash-top">
|
||||
<span class="avatar" />
|
||||
<span class="bar short" />
|
||||
<span class="bar" />
|
||||
</div>
|
||||
<div class="chart-line" />
|
||||
<div class="chart-bars">
|
||||
<i /><i /><i /><i /><i />
|
||||
</div>
|
||||
</div>
|
||||
<span class="float chat" />
|
||||
<span class="float people" />
|
||||
<span class="float check" />
|
||||
<span class="float ring" />
|
||||
<span class="plant" />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-panel {
|
||||
position: relative;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: clamp(1.4rem, 3vw, 2rem);
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(45, 212, 191, 0.18), transparent 42%),
|
||||
linear-gradient(165deg, #f0fdfa 0%, #e6fffa 48%, #f8fafc 100%);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
font-size: 1.2rem;
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.brand-body h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: clamp(1.35rem, 2.2vw, 1.75rem);
|
||||
line-height: 1.45;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.brand-body p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
line-height: 1.75;
|
||||
font-size: 0.95rem;
|
||||
max-width: 28ch;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
position: relative;
|
||||
margin-top: auto;
|
||||
min-height: 220px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dash {
|
||||
position: absolute;
|
||||
inset-inline-start: 12%;
|
||||
bottom: 18%;
|
||||
width: min(78%, 280px);
|
||||
aspect-ratio: 1.35;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.1);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.dash-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #5eead4, #0d9488);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar.short {
|
||||
flex: 0.55;
|
||||
}
|
||||
|
||||
.chart-line {
|
||||
height: 54px;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 55%, rgba(13, 148, 136, 0.12) 55%),
|
||||
radial-gradient(circle at 20% 60%, #14b8a6 3px, transparent 4px),
|
||||
radial-gradient(circle at 45% 35%, #14b8a6 3px, transparent 4px),
|
||||
radial-gradient(circle at 70% 50%, #14b8a6 3px, transparent 4px),
|
||||
radial-gradient(circle at 88% 28%, #14b8a6 3px, transparent 4px);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.chart-bars i {
|
||||
flex: 1;
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
background: linear-gradient(180deg, #5eead4, #0f766e);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chart-bars i:nth-child(1) { height: 45%; }
|
||||
.chart-bars i:nth-child(2) { height: 70%; }
|
||||
.chart-bars i:nth-child(3) { height: 55%; }
|
||||
.chart-bars i:nth-child(4) { height: 88%; }
|
||||
.chart-bars i:nth-child(5) { height: 62%; }
|
||||
|
||||
.float {
|
||||
position: absolute;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.float.chat {
|
||||
top: 18%;
|
||||
inset-inline-end: 8%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 40%, #0d9488 0 28%, transparent 29%),
|
||||
linear-gradient(145deg, #ecfdf5, #fff);
|
||||
}
|
||||
|
||||
.float.people {
|
||||
top: 42%;
|
||||
inset-inline-start: 4%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, #14b8a6 0 22%, transparent 23%),
|
||||
linear-gradient(145deg, #f0fdfa, #fff);
|
||||
}
|
||||
|
||||
.float.check {
|
||||
bottom: 28%;
|
||||
inset-inline-end: 6%;
|
||||
background:
|
||||
linear-gradient(135deg, transparent 42%, #10b981 42% 58%, transparent 58%),
|
||||
linear-gradient(45deg, transparent 48%, #10b981 48% 62%, transparent 62%),
|
||||
#ecfdf5;
|
||||
}
|
||||
|
||||
.float.ring {
|
||||
top: 8%;
|
||||
inset-inline-start: 18%;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
conic-gradient(#0d9488 0 70%, #e2e8f0 70% 100%);
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px));
|
||||
}
|
||||
|
||||
.plant {
|
||||
position: absolute;
|
||||
bottom: 8%;
|
||||
inset-inline-start: 8%;
|
||||
width: 28px;
|
||||
height: 36px;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 20%, #34d399 0 35%, transparent 36%),
|
||||
linear-gradient(180deg, #0d9488 0 55%, #134e4a 55%);
|
||||
border-radius: 40% 40% 8px 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.brand-panel {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+42
-13
@@ -1,6 +1,12 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { AuthUserDto, LoginResponse } from '@mento/shared';
|
||||
import type {
|
||||
AuthUserDto,
|
||||
LoginChallengeResponse,
|
||||
LoginResponse,
|
||||
RegisterChallengeResponse,
|
||||
RegisterOrganizationRequest,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const TOKEN_KEY = 'mento_token';
|
||||
@@ -39,19 +45,40 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
clearLegacy();
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/login', { email, password });
|
||||
async function startLogin(identifier: string, password: string) {
|
||||
const { data } = await api.post<LoginChallengeResponse>('/auth/login', {
|
||||
identifier,
|
||||
password,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function verifyLogin(challengeId: string, code: string) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/login/verify', {
|
||||
challengeId,
|
||||
code,
|
||||
});
|
||||
persist(data);
|
||||
}
|
||||
|
||||
async function register(payload: {
|
||||
organizationName: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
businessField: string;
|
||||
}) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/register', payload);
|
||||
async function startRegister(payload: RegisterOrganizationRequest) {
|
||||
const { data } = await api.post<RegisterChallengeResponse>(
|
||||
'/auth/register',
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function verifyRegister(
|
||||
challengeId: string,
|
||||
emailCode: string,
|
||||
mobileCode: string,
|
||||
) {
|
||||
const { data } = await api.post<LoginResponse>('/auth/register/verify', {
|
||||
challengeId,
|
||||
emailCode,
|
||||
mobileCode,
|
||||
});
|
||||
persist(data);
|
||||
}
|
||||
|
||||
@@ -67,8 +94,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
startLogin,
|
||||
verifyLogin,
|
||||
startRegister,
|
||||
verifyRegister,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,117 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import axios from 'axios';
|
||||
import type { LoginChallengeResponse } from '@mento/shared';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const email = ref('owner@demo.local');
|
||||
const step = ref<'credentials' | 'otp'>('credentials');
|
||||
const identifier = ref('owner@demo.local');
|
||||
const password = ref('demo1234');
|
||||
const showPassword = ref(false);
|
||||
const remember = ref(true);
|
||||
const otpCode = ref('');
|
||||
const challenge = ref<LoginChallengeResponse | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
const otpHint = computed(() => {
|
||||
if (!challenge.value) return '';
|
||||
const dest = challenge.value.maskedDestination ?? '';
|
||||
return challenge.value.channel === 'email'
|
||||
? `کد ارسالشده به ایمیل ${dest} را وارد کنید`
|
||||
: `کد ارسالشده به موبایل ${dest} را وارد کنید`;
|
||||
});
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (!e.response) {
|
||||
return 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.';
|
||||
}
|
||||
const msg = (e.response.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function submitCredentials() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(email.value, password.value);
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
||||
await router.push(redirect);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (!e.response) {
|
||||
error.value = 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.';
|
||||
} else {
|
||||
const msg = (e.response.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ورود ناموفق بود');
|
||||
}
|
||||
} else {
|
||||
error.value = 'ورود ناموفق بود';
|
||||
challenge.value = await auth.startLogin(identifier.value.trim(), password.value);
|
||||
if (challenge.value.devCode) {
|
||||
otpCode.value = challenge.value.devCode;
|
||||
}
|
||||
step.value = 'otp';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ورود ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOtp() {
|
||||
if (!challenge.value) return;
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.verifyLogin(challenge.value.challengeId, otpCode.value.trim());
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
||||
await router.push(redirect);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تأیید کد ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToCredentials() {
|
||||
step.value = 'credentials';
|
||||
otpCode.value = '';
|
||||
challenge.value = null;
|
||||
error.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<section class="panel">
|
||||
<p class="eyebrow">منتو CRM</p>
|
||||
<h1>ورود به پنل</h1>
|
||||
<p class="lead">همترازی قابلیت با دیدار، با تجربه و هویت مستقل منتو (Mento).</p>
|
||||
<div class="auth-page">
|
||||
<div class="auth-shell">
|
||||
<AuthBrandPanel />
|
||||
|
||||
<form class="form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="email">ایمیل</label>
|
||||
<input id="email" v-model="email" type="email" required autocomplete="username" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">رمز عبور</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال ورود…' : 'ورود' }}
|
||||
</button>
|
||||
</form>
|
||||
<section class="form-panel">
|
||||
<header class="mobile-brand">
|
||||
<MentoLogo :size="52" :show-text="false" />
|
||||
<strong>منتو</strong>
|
||||
<span>نرمافزار مدیریت ارتباط با مشتری</span>
|
||||
</header>
|
||||
|
||||
<p class="switch">
|
||||
سازمان ندارید؟
|
||||
<RouterLink to="/register">ثبتنام سازمان</RouterLink>
|
||||
</p>
|
||||
</section>
|
||||
<div class="form-inner">
|
||||
<template v-if="step === 'credentials'">
|
||||
<h1>خوش آمدید 👋</h1>
|
||||
<p class="lead">برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید</p>
|
||||
|
||||
<form class="form" @submit.prevent="submitCredentials">
|
||||
<div class="field">
|
||||
<label for="identifier">ایمیل یا شماره موبایل</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="identifier"
|
||||
v-model="identifier"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="ایمیل یا شماره موبایل خود را وارد کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span class="ico user" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">رمز عبور</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span class="ico lock" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? 'مخفی' : 'نمایش' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-actions">
|
||||
<label class="remember">
|
||||
<input v-model="remember" type="checkbox" />
|
||||
<span>مرا به خاطر بسپار</span>
|
||||
</label>
|
||||
<span class="forgot muted">رمز عبور خود را فراموش کردهاید؟</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال ارسال کد…' : 'ورود' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button type="button" class="back" @click="backToCredentials">بازگشت</button>
|
||||
<h1>تأیید هویت</h1>
|
||||
<p class="lead">{{ otpHint }}</p>
|
||||
<p v-if="challenge?.devCode" class="dev-hint">
|
||||
کد توسعه: <strong>{{ challenge.devCode }}</strong>
|
||||
</p>
|
||||
|
||||
<form class="form" @submit.prevent="submitOtp">
|
||||
<div class="field">
|
||||
<label for="otp">کد یکبارمصرف</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="otp"
|
||||
v-model="otpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="8"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
placeholder="کد را وارد کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال تأیید…' : 'تأیید و ورود' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<p class="switch">
|
||||
حساب کاربری ندارید؟
|
||||
<RouterLink to="/register">ثبتنام کنید</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="auth-footer">
|
||||
<p>۱۴۰۵ منتو. تمامی حقوق محفوظ است.</p>
|
||||
<nav>
|
||||
<a href="#">حریم خصوصی</a>
|
||||
<span>|</span>
|
||||
<a href="#">شرایط و قوانین</a>
|
||||
<span>|</span>
|
||||
<a href="#">تماس با ما</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, rgba(13, 148, 136, 0.08), transparent 36%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(440px, 100%);
|
||||
.auth-shell {
|
||||
flex: 1;
|
||||
width: min(1040px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(1.1rem, 4vw, 1.6rem);
|
||||
overflow: hidden;
|
||||
min-height: min(640px, calc(100dvh - 5.5rem));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
.form-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.2rem, 3vw, 2.4rem);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.4rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.mobile-brand strong {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mobile-brand span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
width: min(420px, 100%);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.35rem 0 0.4rem;
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: clamp(1.45rem, 3vw, 1.85rem);
|
||||
}
|
||||
|
||||
.lead,
|
||||
.switch {
|
||||
.lead {
|
||||
margin: 0 0 1.4rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin: 1.4rem 0 1rem;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrap input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 2.6rem 0.85rem 0.95rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-wrap input:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ico {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: 0.85rem;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ico.user {
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, currentColor 0 28%, transparent 29%),
|
||||
radial-gradient(circle at 50% 85%, currentColor 0 38%, transparent 39%);
|
||||
color: #64748b;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ico.lock {
|
||||
background:
|
||||
linear-gradient(currentColor, currentColor) center 62%/10px 8px no-repeat,
|
||||
radial-gradient(circle at 50% 38%, transparent 34%, currentColor 35% 55%, transparent 56%);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 0.55rem;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.remember {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forgot {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.muted {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin: 1.35rem 0 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.switch a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.back {
|
||||
align-self: flex-start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dev-hint {
|
||||
margin: -0.6rem 0 1rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
background: var(--accent-soft);
|
||||
color: #0f766e;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
width: min(1040px, 100%);
|
||||
margin: 0.85rem auto 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.auth-footer nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.auth-shell {
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 1.1fr);
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
margin: 0;
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,133 +3,611 @@ import { ref } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||||
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import axios from 'axios';
|
||||
import type { RegisterChallengeResponse } from '@mento/shared';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const step = ref<'form' | 'otp'>('form');
|
||||
const organizationName = ref('');
|
||||
const businessField = ref('');
|
||||
const fullName = ref('');
|
||||
const email = ref('');
|
||||
const mobile = ref('');
|
||||
const password = ref('');
|
||||
const passwordConfirm = ref('');
|
||||
const referralCode = ref('');
|
||||
const acceptedTerms = ref(false);
|
||||
const showPassword = ref(false);
|
||||
const showPasswordConfirm = ref(false);
|
||||
const emailCode = ref('');
|
||||
const mobileCode = ref('');
|
||||
const challenge = ref<RegisterChallengeResponse | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (!e.response) {
|
||||
return 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.';
|
||||
}
|
||||
const msg = (e.response.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
error.value = '';
|
||||
if (!acceptedTerms.value) {
|
||||
error.value = 'پذیرش شرایط و قوانین الزامی است';
|
||||
return;
|
||||
}
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
error.value = 'تکرار رمز عبور مطابقت ندارد';
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.register({
|
||||
organizationName: organizationName.value,
|
||||
businessField: businessField.value,
|
||||
fullName: fullName.value,
|
||||
email: email.value,
|
||||
challenge.value = await auth.startRegister({
|
||||
organizationName: organizationName.value.trim(),
|
||||
businessField: businessField.value as (typeof BUSINESS_FIELDS)[number],
|
||||
fullName: fullName.value.trim(),
|
||||
email: email.value.trim(),
|
||||
mobile: mobile.value.trim(),
|
||||
password: password.value,
|
||||
passwordConfirm: passwordConfirm.value,
|
||||
referralCode: referralCode.value.trim() || undefined,
|
||||
acceptedTerms: true,
|
||||
});
|
||||
await router.push('/');
|
||||
if (challenge.value.devEmailCode) emailCode.value = challenge.value.devEmailCode;
|
||||
if (challenge.value.devMobileCode) mobileCode.value = challenge.value.devMobileCode;
|
||||
step.value = 'otp';
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (!e.response) {
|
||||
error.value = 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.';
|
||||
} else {
|
||||
const msg = (e.response.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ثبتنام ناموفق بود');
|
||||
}
|
||||
} else {
|
||||
error.value = 'ثبتنام ناموفق بود';
|
||||
}
|
||||
error.value = apiError(e, 'ثبتنام ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOtp() {
|
||||
if (!challenge.value) return;
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.verifyRegister(
|
||||
challenge.value.challengeId,
|
||||
emailCode.value.trim(),
|
||||
mobileCode.value.trim(),
|
||||
);
|
||||
await router.push('/');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تأیید کد ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToForm() {
|
||||
step.value = 'form';
|
||||
emailCode.value = '';
|
||||
mobileCode.value = '';
|
||||
challenge.value = null;
|
||||
error.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<section class="panel">
|
||||
<p class="eyebrow">شروع با منتو</p>
|
||||
<h1>ساخت سازمان</h1>
|
||||
<p class="lead">اولین کاربر بهعنوان مالک سازمان ثبت میشود. زمینه فعالیت کمک میکند منتو فضای کاری را با صنعت شما همتراز کند.</p>
|
||||
<div class="auth-page">
|
||||
<div class="auth-shell">
|
||||
<AuthBrandPanel
|
||||
headline="همه ارتباطات، یکجا مدیریت میشوند"
|
||||
description="با ساخت حساب منتو، سازمان خود را راهاندازی کنید و فروش و پشتیبانی را از یکجا پیش ببرید."
|
||||
/>
|
||||
|
||||
<form class="form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="org">نام سازمان</label>
|
||||
<input id="org" v-model="organizationName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="field">زمینه فعالیت</label>
|
||||
<select id="field" v-model="businessField" required>
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<option v-for="id in BUSINESS_FIELDS" :key="id" :value="id">
|
||||
{{ BUSINESS_FIELD_LABELS[id] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="name">نام شما</label>
|
||||
<input id="name" v-model="fullName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">ایمیل</label>
|
||||
<input id="email" v-model="email" type="email" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">رمز عبور</label>
|
||||
<input id="password" v-model="password" type="password" minlength="6" required />
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال ثبت…' : 'ایجاد سازمان' }}
|
||||
</button>
|
||||
</form>
|
||||
<section class="form-panel">
|
||||
<header class="mobile-brand">
|
||||
<RouterLink class="back-link" to="/login">بازگشت</RouterLink>
|
||||
<MentoLogo :size="48" :show-text="false" />
|
||||
<strong>منتو</strong>
|
||||
<span>نرمافزار مدیریت ارتباط با مشتری</span>
|
||||
</header>
|
||||
|
||||
<p class="switch">
|
||||
حساب دارید؟
|
||||
<RouterLink to="/login">ورود</RouterLink>
|
||||
</p>
|
||||
</section>
|
||||
<div class="form-inner">
|
||||
<template v-if="step === 'form'">
|
||||
<div class="title-row">
|
||||
<span class="title-ico" aria-hidden="true" />
|
||||
<div>
|
||||
<h1>ایجاد حساب کاربری</h1>
|
||||
<p class="lead">برای شروع، اطلاعات خود را وارد کنید</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="form" @submit.prevent="submitForm">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="org">نام سازمان <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<input id="org" v-model="organizationName" required placeholder="نام سازمان" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="field">زمینه فعالیت <em>*</em></label>
|
||||
<select id="field" v-model="businessField" required>
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<option v-for="id in BUSINESS_FIELDS" :key="id" :value="id">
|
||||
{{ BUSINESS_FIELD_LABELS[id] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="name">نام و نام خانوادگی <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="name"
|
||||
v-model="fullName"
|
||||
required
|
||||
placeholder="نام و نام خانوادگی خود را وارد کنید"
|
||||
/>
|
||||
<span class="ico user" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">ایمیل <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="ایمیل خود را وارد کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span class="ico mail" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="mobile">شماره موبایل <em>*</em></label>
|
||||
<div class="input-wrap mobile-wrap">
|
||||
<span class="cc">+۹۸</span>
|
||||
<input
|
||||
id="mobile"
|
||||
v-model="mobile"
|
||||
type="tel"
|
||||
required
|
||||
placeholder="۹۱۲۱۲۳۴۵۶۷"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span class="ico phone" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">رمز عبور <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
minlength="6"
|
||||
required
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span class="ico lock" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? 'مخفی' : 'نمایش' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="passwordConfirm">تکرار رمز عبور <em>*</em></label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
v-model="passwordConfirm"
|
||||
:type="showPasswordConfirm ? 'text' : 'password'"
|
||||
minlength="6"
|
||||
required
|
||||
placeholder="رمز عبور خود را تکرار کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span class="ico lock" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
@click="showPasswordConfirm = !showPasswordConfirm"
|
||||
>
|
||||
{{ showPasswordConfirm ? 'مخفی' : 'نمایش' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="referral">کد معرف (اختیاری)</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="referral"
|
||||
v-model="referralCode"
|
||||
placeholder="اگر کد معرف دارید وارد کنید"
|
||||
/>
|
||||
<span class="ico gift" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="terms">
|
||||
<input v-model="acceptedTerms" type="checkbox" />
|
||||
<span>با شرایط و قوانین و سیاست حفظ حریم خصوصی موافقم.</span>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال ارسال کد…' : 'ثبت نام' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button type="button" class="back" @click="backToForm">بازگشت</button>
|
||||
<h1>تأیید ایمیل و موبایل</h1>
|
||||
<p class="lead">
|
||||
کدهای یکبارمصرف ارسالشده به
|
||||
{{ challenge?.maskedEmail }} و {{ challenge?.maskedMobile }} را وارد کنید.
|
||||
</p>
|
||||
<p v-if="challenge?.devEmailCode || challenge?.devMobileCode" class="dev-hint">
|
||||
کد ایمیل: <strong>{{ challenge?.devEmailCode }}</strong>
|
||||
—
|
||||
کد موبایل: <strong>{{ challenge?.devMobileCode }}</strong>
|
||||
</p>
|
||||
|
||||
<form class="form" @submit.prevent="submitOtp">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="emailCode">کد ایمیل</label>
|
||||
<input
|
||||
id="emailCode"
|
||||
v-model="emailCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
required
|
||||
dir="ltr"
|
||||
placeholder="کد ایمیل"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mobileCode">کد موبایل</label>
|
||||
<input
|
||||
id="mobileCode"
|
||||
v-model="mobileCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
required
|
||||
dir="ltr"
|
||||
placeholder="کد موبایل"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال تأیید…' : 'تأیید و ایجاد حساب' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<div class="login-cta">
|
||||
<p>قبلاً حساب کاربری دارید؟</p>
|
||||
<RouterLink class="btn-outline" to="/login">ورود به حساب کاربری</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="auth-footer">
|
||||
<p>۱۴۰۵ منتو. تمامی حقوق محفوظ است.</p>
|
||||
<nav>
|
||||
<a href="#">پشتیبانی</a>
|
||||
<span>|</span>
|
||||
<a href="#">تماس با ما</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(13, 148, 136, 0.07), transparent 34%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(480px, 100%);
|
||||
.auth-shell {
|
||||
flex: 1;
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(1.1rem, 4vw, 1.6rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
.form-panel {
|
||||
padding: clamp(1rem, 2.5vw, 2rem);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: 0.2rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-brand strong {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mobile-brand span {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
width: min(640px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.title-ico {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, #0d9488 0 18%, transparent 19%),
|
||||
linear-gradient(145deg, #ccfbf1, #f0fdfa);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, white);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-ico::after {
|
||||
content: '+';
|
||||
position: absolute;
|
||||
inset-inline-end: 6px;
|
||||
bottom: 4px;
|
||||
color: #0d9488;
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.35rem 0 0.4rem;
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: clamp(1.3rem, 2.5vw, 1.65rem);
|
||||
}
|
||||
|
||||
.lead,
|
||||
.switch {
|
||||
.lead {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin: 1.4rem 0 1rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.field em {
|
||||
color: var(--danger);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.field select,
|
||||
.input-wrap input,
|
||||
.form > .grid-2 .field > input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.8rem 0.95rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrap input {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
|
||||
.mobile-wrap input {
|
||||
padding-inline-start: 3.4rem;
|
||||
}
|
||||
|
||||
.cc {
|
||||
position: absolute;
|
||||
inset-inline-start: 0.7rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ico {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: 0.8rem;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
background: #64748b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ico.user {
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, #64748b 0 28%, transparent 29%),
|
||||
radial-gradient(circle at 50% 85%, #64748b 0 38%, transparent 39%);
|
||||
}
|
||||
|
||||
.ico.mail {
|
||||
background: linear-gradient(#64748b, #64748b);
|
||||
mask: linear-gradient(#000 0 0);
|
||||
opacity: 0.4;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ico.lock,
|
||||
.ico.phone,
|
||||
.ico.gift {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 0.45rem;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terms {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.login-cta {
|
||||
margin-top: 1.4rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-cta p {
|
||||
margin: 0 0 0.55rem;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-width: min(100%, 280px);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.back {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dev-hint {
|
||||
margin: 0.75rem 0 1rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
background: var(--accent-soft);
|
||||
color: #0f766e;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
width: min(1100px, 100%);
|
||||
margin: 0.85rem auto 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.auth-footer nav {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.auth-shell {
|
||||
grid-template-columns: minmax(260px, 0.85fr) minmax(380px, 1.15fr);
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,7 @@ export function canReassignRecords(role: UserRole | string | null | undefined):
|
||||
export interface AuthUserDto {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
organizationId: string;
|
||||
@@ -64,7 +65,8 @@ export interface ApiErrorBody {
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
/** ایمیل یا شماره موبایل */
|
||||
identifier: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -72,8 +74,41 @@ export interface RegisterOrganizationRequest {
|
||||
organizationName: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
businessField: BusinessField;
|
||||
referralCode?: string;
|
||||
acceptedTerms?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginChallengeResponse {
|
||||
challengeId: string;
|
||||
channel: 'email' | 'mobile';
|
||||
expiresInSeconds: number;
|
||||
maskedDestination?: string;
|
||||
/** فقط در محیط توسعه / AUTH_RETURN_OTP */
|
||||
devCode?: string;
|
||||
}
|
||||
|
||||
export interface RegisterChallengeResponse {
|
||||
challengeId: string;
|
||||
expiresInSeconds: number;
|
||||
maskedEmail?: string;
|
||||
maskedMobile?: string;
|
||||
devEmailCode?: string;
|
||||
devMobileCode?: string;
|
||||
}
|
||||
|
||||
export interface LoginVerifyRequest {
|
||||
challengeId: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface RegisterVerifyRequest {
|
||||
challengeId: string;
|
||||
emailCode: string;
|
||||
mobileCode: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
|
||||
Reference in New Issue
Block a user