Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization. Co-authored-by: Cursor <cursoragent@cursor.com>
1023 lines
34 KiB
TypeScript
1023 lines
34 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import * as path from 'path';
|
|
import type {
|
|
AuthUserDto,
|
|
LoginResponse,
|
|
LoginResult,
|
|
OrgModuleKey,
|
|
StorageUsageDto,
|
|
SubscriptionSummaryDto,
|
|
} from '@mento/shared';
|
|
import {
|
|
parseUserTreasuryAccess,
|
|
permissionsWithTreasuryOverride,
|
|
} from '@mento/shared';
|
|
import { Prisma } from '@prisma/client';
|
|
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
|
import type {} from 'multer';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
|
import { MembershipsService } from '../memberships/memberships.service';
|
|
import { BillingService } from '../billing/billing.service';
|
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
|
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
|
import { AttendanceService } from '../attendance/attendance.service';
|
|
import type { Actor } from '../common/record-scope';
|
|
import {
|
|
LoginDto,
|
|
LoginOtpRequestDto,
|
|
LoginOtpVerifyDto,
|
|
RegisterOrganizationDto,
|
|
RegisterVerifyOtpDto,
|
|
UpdateProfileDto,
|
|
} from './dto';
|
|
|
|
const OTP_LENGTH = 6;
|
|
const OTP_TTL_SECONDS = 90;
|
|
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
|
const AVATAR_EXT: Record<string, string> = {
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/png': 'png',
|
|
'image/webp': 'webp',
|
|
'image/gif': 'gif',
|
|
};
|
|
|
|
function slugify(input: string): string {
|
|
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 '****';
|
|
// نمایش RTL: دو رقم آخر سپس *** سپس پیششماره (مثلاً 69***0913)
|
|
return `${mobile.slice(-2)}***${mobile.slice(0, 4)}`;
|
|
}
|
|
|
|
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: SmsGatewayService,
|
|
private readonly files: LocalFileStorage,
|
|
private readonly memberships: MembershipsService,
|
|
private readonly billing: BillingService,
|
|
private readonly storageQuota: StorageQuotaService,
|
|
private readonly adminAlerts: PlatformAdminAlertService,
|
|
private readonly attendance: AttendanceService,
|
|
) {}
|
|
|
|
/**
|
|
* ثبتنام مرحله ۱:
|
|
* - همیشه OTP موبایل
|
|
* - OTP ایمیل فقط وقتی AUTH_EMAIL_OTP_ENABLED=true
|
|
*/
|
|
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 returnOtp = this.shouldReturnOtp();
|
|
const emailOtpEnabled = this.isEmailOtpConfigured();
|
|
const mobileCode = generateOtp();
|
|
const emailCode = emailOtpEnabled ? generateOtp() : null;
|
|
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),
|
|
},
|
|
});
|
|
|
|
if (emailOtpEnabled && emailCode) {
|
|
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
|
}
|
|
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null);
|
|
|
|
return {
|
|
challengeId: challenge.id,
|
|
expiresInSeconds: OTP_TTL_SECONDS,
|
|
maskedEmail: maskEmail(email),
|
|
maskedMobile: maskMobile(mobile),
|
|
emailOtpRequired: emailOtpEnabled,
|
|
...(returnOtp
|
|
? {
|
|
...(emailCode ? { devEmailCode: emailCode } : {}),
|
|
devMobileCode: mobileCode,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/** ثبتنام مرحله ۲: تأیید OTP و ساخت سازمان */
|
|
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 mobileOk = challenge.mobileCode === dto.mobileCode.trim();
|
|
if (!mobileOk) {
|
|
throw new UnauthorizedException('کد تأیید موبایل نادرست است');
|
|
}
|
|
|
|
const emailRequired = Boolean(challenge.emailCode);
|
|
if (emailRequired) {
|
|
const emailOk = challenge.emailCode === (dto.emailCode ?? '').trim();
|
|
if (!emailOk) {
|
|
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 now = new Date();
|
|
const organization = await this.prisma.organization.create({
|
|
data: {
|
|
name: payload.organizationName,
|
|
slug,
|
|
businessField: payload.businessField,
|
|
users: {
|
|
create: {
|
|
email,
|
|
mobile,
|
|
fullName: payload.fullName,
|
|
passwordHash: challenge.passwordHash,
|
|
role: 'owner',
|
|
emailVerifiedAt: emailRequired ? now : null,
|
|
mobileVerifiedAt: now,
|
|
},
|
|
},
|
|
},
|
|
include: { users: true },
|
|
});
|
|
|
|
const owner = organization.users[0];
|
|
if (!owner) {
|
|
throw new BadRequestException('ساخت حساب ناموفق بود');
|
|
}
|
|
await this.memberships.createMembership({
|
|
userId: owner.id,
|
|
organizationId: organization.id,
|
|
role: 'owner',
|
|
});
|
|
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
|
await this.prisma.organization.update({
|
|
where: { id: organization.id },
|
|
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
|
});
|
|
await this.adminAlerts.notifyNewAccount({
|
|
source: 'signup',
|
|
organizationName: organization.name,
|
|
ownerName: owner.fullName,
|
|
mobile: owner.mobile,
|
|
});
|
|
|
|
await this.prisma.authChallenge.update({
|
|
where: { id: challenge.id },
|
|
data: {
|
|
consumedAt: now,
|
|
emailVerified: emailRequired,
|
|
mobileVerified: true,
|
|
},
|
|
});
|
|
|
|
return this.issueLogin(organization.users[0]!.id, organization.id);
|
|
}
|
|
|
|
/** ورود با رمز: ایمیل یا موبایل + رمز → JWT (بدون OTP) */
|
|
async loginWithPassword(dto: LoginDto): Promise<LoginResult> {
|
|
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 ? { email } : { mobile: mobile! },
|
|
});
|
|
if (!user) {
|
|
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
|
}
|
|
if (user.isActive === false) {
|
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
|
}
|
|
|
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
|
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
|
|
if (!ok && !viaMaster) {
|
|
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
|
}
|
|
|
|
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
|
|
}
|
|
|
|
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
|
async requestLoginOtp(dto: LoginOtpRequestDto) {
|
|
const mobile = normalizeMobile(dto.mobile);
|
|
if (!mobile) {
|
|
throw new BadRequestException('شماره موبایل معتبر نیست');
|
|
}
|
|
|
|
const user = await this.prisma.user.findFirst({ where: { mobile } });
|
|
if (!user) {
|
|
throw new UnauthorizedException('حسابی با این موبایل یافت نشد');
|
|
}
|
|
if (user.isActive === false) {
|
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
|
}
|
|
|
|
await this.throttleChallenges('login', user.email, mobile);
|
|
|
|
const code = generateOtp();
|
|
const returnOtp = this.shouldReturnOtp();
|
|
const challenge = await this.prisma.authChallenge.create({
|
|
data: {
|
|
kind: 'login',
|
|
channel: 'mobile',
|
|
email: user.email,
|
|
mobile,
|
|
payload: { userId: user.id } as Prisma.InputJsonValue,
|
|
mobileCode: code,
|
|
expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000),
|
|
},
|
|
});
|
|
|
|
await this.dispatchMobileOtp(mobile, code, returnOtp, user.organizationId);
|
|
|
|
return {
|
|
challengeId: challenge.id,
|
|
channel: 'mobile' as const,
|
|
expiresInSeconds: OTP_TTL_SECONDS,
|
|
maskedDestination: maskMobile(mobile),
|
|
...(returnOtp ? { devCode: code } : {}),
|
|
};
|
|
}
|
|
|
|
async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise<LoginResult> {
|
|
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('کد تأیید نامعتبر یا منقضی است');
|
|
}
|
|
|
|
if (!challenge.mobileCode || challenge.mobileCode !== dto.code.trim()) {
|
|
throw new UnauthorizedException('کد تأیید نادرست است');
|
|
}
|
|
|
|
const payload = (challenge.payload ?? {}) as { userId?: string };
|
|
if (!payload.userId) {
|
|
throw new BadRequestException('چالش ورود ناقص است');
|
|
}
|
|
|
|
const user = await this.prisma.user.findUnique({ where: { id: payload.userId } });
|
|
if (!user) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
if (user.isActive === false) {
|
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
|
}
|
|
|
|
const now = new Date();
|
|
await this.prisma.authChallenge.update({
|
|
where: { id: challenge.id },
|
|
data: { consumedAt: now, mobileVerified: true },
|
|
});
|
|
if (!user.mobileVerifiedAt) {
|
|
await this.prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { mobileVerifiedAt: now },
|
|
});
|
|
}
|
|
|
|
return this.finishLogin(user.id);
|
|
}
|
|
|
|
async logout(actor: Actor) {
|
|
return this.attendance.recordLogout(actor);
|
|
}
|
|
|
|
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
|
|
await this.memberships.requireActive(userId, organizationId);
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
let impersonatorFullName: string | undefined;
|
|
if (actor?.viewAsReadOnly && actor.impersonatorUserId) {
|
|
const owner = await this.prisma.user.findUnique({
|
|
where: { id: actor.impersonatorUserId },
|
|
select: { fullName: true },
|
|
});
|
|
impersonatorFullName = owner?.fullName;
|
|
}
|
|
return this.toAuthUserDto(user, organizationId, undefined, actor, impersonatorFullName);
|
|
}
|
|
|
|
/**
|
|
* مالک محیط: مشاهده فقطخواندنی بهجای کاربر هدف، یا بازگشت به حساب خودش.
|
|
*/
|
|
async viewAs(actor: Actor, targetUserId: string): Promise<LoginResponse> {
|
|
const ownerId = actor.impersonatorUserId ?? actor.userId;
|
|
const ownerMembership = await this.prisma.membership.findFirst({
|
|
where: {
|
|
userId: ownerId,
|
|
organizationId: actor.organizationId,
|
|
isActive: true,
|
|
role: 'owner',
|
|
},
|
|
select: { userId: true, organizationId: true },
|
|
});
|
|
if (!ownerMembership) {
|
|
throw new ForbiddenException('فقط مالک محیط میتواند بهجای کاربر دیگر مشاهده کند.');
|
|
}
|
|
|
|
if (!targetUserId?.trim()) {
|
|
throw new BadRequestException('کاربر هدف مشخص نیست.');
|
|
}
|
|
|
|
if (targetUserId === ownerId) {
|
|
return this.issueLogin(ownerId, actor.organizationId, { skipLastLogin: true });
|
|
}
|
|
|
|
const target = await this.prisma.user.findFirst({
|
|
where: {
|
|
id: targetUserId,
|
|
isActive: true,
|
|
memberships: {
|
|
some: {
|
|
organizationId: actor.organizationId,
|
|
isActive: true,
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
mobile: true,
|
|
fullName: true,
|
|
memberships: {
|
|
where: { organizationId: actor.organizationId, isActive: true },
|
|
select: { role: true },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
if (!target?.memberships[0]) {
|
|
throw new NotFoundException('کاربر در این محیط پیدا نشد.');
|
|
}
|
|
|
|
const role = target.memberships[0].role as AuthUserDto['role'];
|
|
const subscription = await this.billing.requireActiveForOrganization(
|
|
actor.organizationId,
|
|
);
|
|
const accessToken = this.jwt.sign({
|
|
sub: target.id,
|
|
organizationId: actor.organizationId,
|
|
role,
|
|
impersonatorUserId: ownerId,
|
|
viewAsReadOnly: true,
|
|
});
|
|
const ownerUser = await this.prisma.user.findUnique({
|
|
where: { id: ownerId },
|
|
select: { fullName: true },
|
|
});
|
|
const userDto = await this.toAuthUserDto(
|
|
{ ...target, role, organizationId: actor.organizationId },
|
|
actor.organizationId,
|
|
subscription,
|
|
{
|
|
userId: target.id,
|
|
role,
|
|
organizationId: actor.organizationId,
|
|
impersonatorUserId: ownerId,
|
|
impersonatorRole: 'owner',
|
|
viewAsReadOnly: true,
|
|
},
|
|
ownerUser?.fullName ?? 'مالک',
|
|
);
|
|
return { accessToken, user: userDto };
|
|
}
|
|
|
|
async setPriorLens(
|
|
userId: string,
|
|
organizationId: string,
|
|
lens: 'current' | 'archive',
|
|
actor?: Actor,
|
|
): Promise<AuthUserDto> {
|
|
const membership = await this.prisma.membership.findFirst({
|
|
where: { userId, organizationId, isActive: true },
|
|
include: { organization: { select: { enabledModules: true } } },
|
|
});
|
|
if (!membership?.priorArchiveEnabled) {
|
|
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست');
|
|
}
|
|
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
|
if (!modules.includes('prior_data_archive')) {
|
|
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
|
|
}
|
|
await this.prisma.membership.update({
|
|
where: { id: membership.id },
|
|
data: { priorArchiveLens: lens },
|
|
});
|
|
return this.me(userId, organizationId, actor);
|
|
}
|
|
|
|
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
|
|
const fullName = dto.fullName.trim();
|
|
if (fullName.length < 2) {
|
|
throw new BadRequestException('نام باید حداقل دو حرف باشد');
|
|
}
|
|
const email = dto.email.trim().toLowerCase();
|
|
const mobile = normalizeMobile(dto.mobile);
|
|
if (!mobile) {
|
|
throw new BadRequestException('شماره موبایل معتبر نیست');
|
|
}
|
|
|
|
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!existing) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
if (email !== existing.email) {
|
|
const taken = await this.prisma.user.findFirst({
|
|
where: { email, id: { not: userId } },
|
|
});
|
|
if (taken) {
|
|
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
|
}
|
|
}
|
|
if (mobile !== existing.mobile) {
|
|
const taken = await this.prisma.user.findFirst({
|
|
where: { mobile, id: { not: userId } },
|
|
});
|
|
if (taken) {
|
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
|
}
|
|
}
|
|
|
|
try {
|
|
const user = await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: { fullName, email, mobile },
|
|
});
|
|
return this.toAuthUserDto(user, organizationId);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
error.code === 'P2002'
|
|
) {
|
|
throw new ConflictException('این ایمیل یا شماره موبایل قبلاً ثبت شده است');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async saveAvatar(
|
|
userId: string,
|
|
organizationId: string,
|
|
file: Express.Multer.File | undefined,
|
|
): Promise<AuthUserDto> {
|
|
if (!file?.buffer?.length) {
|
|
throw new BadRequestException('فایل تصویر ارسال نشده');
|
|
}
|
|
if (file.size > AVATAR_MAX_BYTES) {
|
|
throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد');
|
|
}
|
|
const mime = (file.mimetype || '').toLowerCase();
|
|
const ext = AVATAR_EXT[mime];
|
|
if (!ext) {
|
|
throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است');
|
|
}
|
|
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!existing) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
const prevKey = await this.readAvatarKey(userId);
|
|
const storageKey = path
|
|
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
|
.replace(/\\/g, '/');
|
|
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
|
|
const delta = file.size - prevBytes;
|
|
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
|
|
await this.files.save(storageKey, file.buffer);
|
|
if (prevKey && prevKey !== storageKey) {
|
|
await this.files.remove(prevKey);
|
|
}
|
|
await this.writeAvatarKey(userId, storageKey);
|
|
if (delta) await this.storageQuota.addLocal(organizationId, delta);
|
|
return this.toAuthUserDto(existing, organizationId);
|
|
}
|
|
|
|
async removeAvatar(userId: string, organizationId: string): Promise<AuthUserDto> {
|
|
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!existing) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
const prevKey = await this.readAvatarKey(userId);
|
|
if (prevKey) {
|
|
const bytes = await this.files.sizeOf(prevKey);
|
|
await this.files.remove(prevKey);
|
|
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
|
|
}
|
|
await this.writeAvatarKey(userId, null);
|
|
return this.toAuthUserDto(existing, organizationId);
|
|
}
|
|
|
|
async openAvatar(userId: string) {
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
const storageKey = await this.readAvatarKey(userId);
|
|
if (!storageKey) {
|
|
throw new NotFoundException('عکس پروفایل تنظیم نشده');
|
|
}
|
|
const ext = path.extname(storageKey).slice(1).toLowerCase();
|
|
const mime =
|
|
Object.entries(AVATAR_EXT).find(([, value]) => value === ext)?.[0] ??
|
|
'image/jpeg';
|
|
return {
|
|
stream: this.files.openReadStream(storageKey),
|
|
mimeType: mime,
|
|
};
|
|
}
|
|
|
|
private async readAvatarKey(userId: string): Promise<string | null> {
|
|
const rows = await this.prisma.$queryRaw<Array<{ avatarStorageKey: string | null }>>`
|
|
SELECT "avatarStorageKey" FROM "User" WHERE "id" = ${userId}
|
|
`;
|
|
return rows[0]?.avatarStorageKey ?? null;
|
|
}
|
|
|
|
private async writeAvatarKey(userId: string, storageKey: string | null) {
|
|
await this.prisma.$executeRaw`
|
|
UPDATE "User" SET "avatarStorageKey" = ${storageKey} WHERE "id" = ${userId}
|
|
`;
|
|
}
|
|
|
|
async listMyOrganizations(userId: string) {
|
|
const rows = await this.memberships.listActive(userId);
|
|
return this.memberships.toChoices(rows);
|
|
}
|
|
|
|
async selectOrganization(
|
|
selectionToken: string,
|
|
organizationId: string,
|
|
): Promise<LoginResponse> {
|
|
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
|
|
try {
|
|
payload = this.jwt.verify<{
|
|
sub?: string;
|
|
typ?: string;
|
|
pwd?: boolean;
|
|
master?: boolean;
|
|
}>(selectionToken);
|
|
} catch {
|
|
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
|
}
|
|
if (payload.typ !== 'org-select' || !payload.sub) {
|
|
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
|
|
}
|
|
return this.issueLogin(payload.sub, organizationId, {
|
|
fromPassword: payload.pwd === true,
|
|
viaMaster: payload.master === true,
|
|
skipLastLogin: payload.master === true,
|
|
});
|
|
}
|
|
|
|
async switchOrganization(
|
|
userId: string,
|
|
organizationId: string,
|
|
): Promise<LoginResponse> {
|
|
return this.issueLogin(userId, organizationId);
|
|
}
|
|
|
|
private async finishLogin(
|
|
userId: string,
|
|
opts?: { fromPassword?: boolean; viaMaster?: boolean },
|
|
): Promise<LoginResult> {
|
|
const rows = await this.memberships.listActive(userId);
|
|
if (!rows.length) {
|
|
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
|
}
|
|
const usable =
|
|
opts?.fromPassword && !opts.viaMaster
|
|
? rows.filter((row) => !row.loginOtpOnly)
|
|
: rows;
|
|
if (!usable.length) {
|
|
throw new UnauthorizedException(
|
|
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
|
);
|
|
}
|
|
if (usable.length === 1) {
|
|
return this.issueLogin(userId, usable[0]!.organization.id, {
|
|
fromPassword: opts?.fromPassword,
|
|
viaMaster: opts?.viaMaster,
|
|
skipLastLogin: opts?.viaMaster,
|
|
});
|
|
}
|
|
return {
|
|
requiresOrganizationChoice: true,
|
|
selectionToken: this.jwt.sign(
|
|
{
|
|
sub: userId,
|
|
typ: 'org-select',
|
|
...(opts?.fromPassword ? { pwd: true } : {}),
|
|
...(opts?.viaMaster ? { master: true } : {}),
|
|
},
|
|
{ expiresIn: '10m' },
|
|
),
|
|
organizations: this.memberships.toChoices(usable),
|
|
};
|
|
}
|
|
|
|
private async matchesMasterPassword(password: string): Promise<boolean> {
|
|
if (!password) return false;
|
|
const row = await this.prisma.platformMasterPassword.findUnique({
|
|
where: { id: 'default' },
|
|
select: { passwordHash: true },
|
|
});
|
|
if (!row?.passwordHash) return false;
|
|
return bcrypt.compare(password, row.passwordHash);
|
|
}
|
|
|
|
private async issueLogin(
|
|
userId: string,
|
|
organizationId: string,
|
|
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
|
|
): Promise<LoginResponse> {
|
|
const membership = await this.memberships.requireActive(userId, organizationId);
|
|
if (opts?.fromPassword && !opts.viaMaster) {
|
|
const gate = await this.prisma.membership.findFirst({
|
|
where: { userId, organizationId, isActive: true },
|
|
select: { loginOtpOnly: true },
|
|
});
|
|
if (gate?.loginOtpOnly) {
|
|
throw new UnauthorizedException(
|
|
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
|
);
|
|
}
|
|
}
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user || user.isActive === false) {
|
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
|
}
|
|
const subscription = await this.billing.requireActiveForOrganization(
|
|
organizationId,
|
|
);
|
|
if (!opts?.skipLastLogin) {
|
|
await this.attendance.assertLoginAllowed(organizationId, userId);
|
|
}
|
|
await this.memberships.syncUserLastOrg(
|
|
userId,
|
|
organizationId,
|
|
membership.role,
|
|
);
|
|
if (!opts?.skipLastLogin) {
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: { lastLoginAt: new Date() },
|
|
});
|
|
await this.attendance.recordLogin(organizationId, userId, membership.role);
|
|
}
|
|
const accessToken = this.jwt.sign({
|
|
sub: userId,
|
|
organizationId,
|
|
role: membership.role,
|
|
});
|
|
return {
|
|
accessToken,
|
|
user: await this.toAuthUserDto(
|
|
{ ...user, role: membership.role, organizationId },
|
|
organizationId,
|
|
subscription,
|
|
),
|
|
};
|
|
}
|
|
|
|
private async toAuthUserDto(
|
|
user: {
|
|
id: string;
|
|
email: string;
|
|
mobile?: string | null;
|
|
fullName: string;
|
|
role: AuthUserDto['role'];
|
|
organizationId: string;
|
|
},
|
|
organizationId: string,
|
|
subscription?: SubscriptionSummaryDto | null,
|
|
actor?: Actor,
|
|
impersonatorFullName?: string,
|
|
): Promise<AuthUserDto> {
|
|
const membership = await this.prisma.membership.findFirst({
|
|
where: { userId: user.id, organizationId, isActive: true },
|
|
include: {
|
|
organization: {
|
|
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
|
|
},
|
|
orgRole: { select: { permissions: true } },
|
|
},
|
|
});
|
|
const role = membership?.role ?? user.role;
|
|
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
|
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
|
: [];
|
|
const orgName = membership?.organization.name;
|
|
const sub =
|
|
subscription ??
|
|
(await this.billing.requireActiveForOrganization(organizationId));
|
|
const viewAs =
|
|
actor?.viewAsReadOnly && actor.impersonatorUserId
|
|
? {
|
|
impersonatorId: actor.impersonatorUserId,
|
|
impersonatorFullName: impersonatorFullName?.trim() || 'مالک',
|
|
canOpenTeamSettings: true,
|
|
}
|
|
: undefined;
|
|
let storage: StorageUsageDto | undefined;
|
|
try {
|
|
storage = await this.storageQuota.usageForOrganization(organizationId);
|
|
} catch {
|
|
storage = undefined;
|
|
}
|
|
return this.toAuthUser({
|
|
...user,
|
|
role,
|
|
organizationId,
|
|
organizationName: orgName,
|
|
avatarStorageKey: await this.readAvatarKey(user.id),
|
|
enabledModules: this.billing.normalizeModules(
|
|
membership?.organization.enabledModules,
|
|
),
|
|
subscription: sub,
|
|
storage,
|
|
isOrgOwner: role === 'owner',
|
|
priorArchive: this.priorArchiveFor(membership),
|
|
permissions: permissionsWithTreasuryOverride(
|
|
rolePermissions,
|
|
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
|
) as AuthUserDto['permissions'],
|
|
...(viewAs ? { viewAs } : {}),
|
|
});
|
|
}
|
|
|
|
private priorArchiveFor(
|
|
membership: {
|
|
priorArchiveEnabled?: boolean;
|
|
priorArchiveLens?: string;
|
|
organization?: { enabledModules?: string[]; priorArchiveEnabledAt?: Date | null };
|
|
} | null,
|
|
): AuthUserDto['priorArchive'] {
|
|
if (!membership?.priorArchiveEnabled) return undefined;
|
|
const modules = this.billing.normalizeModules(membership.organization?.enabledModules);
|
|
if (!modules.includes('prior_data_archive')) return undefined;
|
|
const orgCutoff = membership.organization?.priorArchiveEnabledAt ?? null;
|
|
return {
|
|
lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current',
|
|
cutoffAt: orgCutoff ? orgCutoff.toISOString() : null,
|
|
};
|
|
}
|
|
|
|
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 isEmailOtpConfigured(): boolean {
|
|
return this.config.get<string>('AUTH_EMAIL_OTP_ENABLED') === 'true';
|
|
}
|
|
|
|
private async dispatchEmailOtp(
|
|
email: string,
|
|
code: string,
|
|
returnOtp: boolean,
|
|
) {
|
|
this.logger.log(`[auth-otp-email] ${email} => ${code}`);
|
|
if (!this.isEmailOtpConfigured() && !returnOtp) {
|
|
throw new BadRequestException(
|
|
'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async dispatchMobileOtp(
|
|
mobile: string,
|
|
code: string,
|
|
returnOtp: boolean,
|
|
organizationId: string | null,
|
|
) {
|
|
if (this.sms.isConfigured() || organizationId) {
|
|
const sent = await this.sms.sendOtp(organizationId, 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 toAuthUser(user: {
|
|
id: string;
|
|
email: string;
|
|
mobile?: string | null;
|
|
fullName: string;
|
|
role: AuthUserDto['role'];
|
|
organizationId: string;
|
|
organizationName?: string;
|
|
avatarStorageKey?: string | null;
|
|
enabledModules?: OrgModuleKey[];
|
|
subscription?: SubscriptionSummaryDto | null;
|
|
storage?: StorageUsageDto | null;
|
|
isOrgOwner?: boolean;
|
|
priorArchive?: AuthUserDto['priorArchive'];
|
|
viewAs?: AuthUserDto['viewAs'];
|
|
permissions?: AuthUserDto['permissions'];
|
|
}): AuthUserDto {
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
fullName: user.fullName,
|
|
role: user.role,
|
|
organizationId: user.organizationId,
|
|
organizationName: user.organizationName,
|
|
hasAvatar: Boolean(user.avatarStorageKey),
|
|
...(user.mobile ? { mobile: user.mobile } : {}),
|
|
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
|
...(user.subscription ? { subscription: user.subscription } : {}),
|
|
...(user.storage ? { storage: user.storage } : {}),
|
|
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
|
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
|
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
|
...(user.permissions ? { permissions: user.permissions } : {}),
|
|
};
|
|
}
|
|
}
|