Redesign login/register with Mento branding and email/mobile OTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 00:47:33 +03:30
co-authored by Cursor
parent aa3fe49ce6
commit c620d1d6d4
12 changed files with 1792 additions and 184 deletions
@@ -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");
+32
View File
@@ -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())
+20
View File
@@ -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');
+18 -3
View File
@@ -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)
+3 -1
View File
@@ -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 {}
+375 -28
View File
@@ -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 } : {}),
};
}
}
+52 -1
View File
@@ -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
View File
@@ -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,
};
});
+387 -64
View File
@@ -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>
+551 -73
View File
@@ -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>
+36 -1
View File
@@ -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 {