feat(adm): split chat and billing settings and add a master login

Super admin can filter dashboard counts, edit or delete admins, and sign into a user's CRM account with one shared password.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 19:13:57 +03:30
co-authored by Cursor
parent acb5d51f24
commit c273dd6c58
16 changed files with 1031 additions and 253 deletions
@@ -0,0 +1,7 @@
CREATE TABLE "PlatformMasterPassword" (
"id" TEXT NOT NULL DEFAULT 'default',
"passwordHash" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
);
+7
View File
@@ -451,6 +451,13 @@ model PlatformAdmin {
updatedAt DateTime @updatedAt
}
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز می‌شود وارد حساب CRM او شد
model PlatformMasterPassword {
id String @id @default("default")
passwordHash String
updatedAt DateTime @updatedAt
}
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
model BillingAccount {
id String @id @default(cuid())
+32 -12
View File
@@ -326,11 +326,12 @@ export class AuthService {
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
if (!ok && !viaMaster) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
return this.finishLogin(user.id, { fromPassword: true });
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
}
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -692,11 +693,14 @@ export class AuthService {
selectionToken: string,
organizationId: string,
): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string; pwd?: boolean };
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
try {
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>(
selectionToken,
);
payload = this.jwt.verify<{
sub?: string;
typ?: string;
pwd?: boolean;
master?: boolean;
}>(selectionToken);
} catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
}
@@ -705,6 +709,8 @@ export class AuthService {
}
return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true,
viaMaster: payload.master === true,
skipLastLogin: payload.master === true,
});
}
@@ -717,15 +723,16 @@ export class AuthService {
private async finishLogin(
userId: string,
opts?: { fromPassword?: boolean },
opts?: { fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId);
if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
}
const usable = opts?.fromPassword
? rows.filter((row) => !row.loginOtpOnly)
: rows;
const usable =
opts?.fromPassword && !opts.viaMaster
? rows.filter((row) => !row.loginOtpOnly)
: rows;
if (!usable.length) {
throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
@@ -734,6 +741,8 @@ export class AuthService {
if (usable.length === 1) {
return this.issueLogin(userId, usable[0]!.organization.id, {
fromPassword: opts?.fromPassword,
viaMaster: opts?.viaMaster,
skipLastLogin: opts?.viaMaster,
});
}
return {
@@ -743,6 +752,7 @@ export class AuthService {
sub: userId,
typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}),
...(opts?.viaMaster ? { master: true } : {}),
},
{ expiresIn: '10m' },
),
@@ -750,13 +760,23 @@ export class AuthService {
};
}
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 },
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
if (opts?.fromPassword) {
if (opts?.fromPassword && !opts.viaMaster) {
const gate = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { loginOtpOnly: true },
@@ -656,7 +656,7 @@ export class ConversationsService {
});
if (!row) {
throw new BadRequestException(
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیط‌ها → تنظیمات گفتگو پیکربندی کنید.',
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
);
}
try {
+25
View File
@@ -54,6 +54,31 @@ export class ResetPlatformAdminPasswordDto {
password!: string;
}
export class UpdatePlatformAdminDto {
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsEmail()
email?: string | null;
}
export class SetMasterPasswordDto {
@ValidateIf((_, value) => value !== null)
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string | null;
}
export class UpdatePlatformStatusDto {
@IsBoolean()
isActive!: boolean;
@@ -1,10 +1,12 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -16,6 +18,8 @@ import {
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
SetMasterPasswordDto,
UpdatePlatformAdminDto,
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformMembershipRoleDto,
@@ -73,6 +77,26 @@ export class PlatformController {
return this.platform.resetAdminPassword(id, dto);
}
@Patch('admins/:id/profile')
updateAdmin(@Param('id') id: string, @Body() dto: UpdatePlatformAdminDto) {
return this.platform.updateAdmin(id, dto);
}
@Delete('admins/:id')
deleteAdmin(@Param('id') id: string, @CurrentUser() actor: PlatformActor) {
return this.platform.deleteAdmin(id, actor.platformAdminId);
}
@Get('master-password')
masterPasswordStatus() {
return this.platform.masterPasswordStatus();
}
@Put('master-password')
setMasterPassword(@Body() dto: SetMasterPasswordDto) {
return this.platform.setMasterPassword(dto.password);
}
@Get('organizations')
listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q);
+108 -2
View File
@@ -111,15 +111,23 @@ export class PlatformService {
where: { role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
take: 1,
select: { user: { select: { billingAccount: true } } },
select: {
userId: true,
user: { select: { fullName: true, billingAccount: true } },
},
},
conversationSettings: { select: { organizationId: true } },
},
});
return rows.map((row) => {
const account = row.memberships[0]?.user.billingAccount;
const owner = row.memberships[0];
const account = owner?.user.billingAccount;
return {
...this.orgToDto(row),
subscription: account ? toSummary(account) : null,
ownerUserId: owner?.userId ?? null,
ownerName: owner?.user.fullName ?? null,
chatConfigured: Boolean(row.conversationSettings),
};
});
}
@@ -698,6 +706,104 @@ export class PlatformService {
return { ok: true };
}
async updateAdmin(
id: string,
dto: { fullName: string; mobile: string; email?: string | null },
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const email = dto.email?.trim().toLowerCase() || null;
const mobileTaken = await this.prisma.platformAdmin.findFirst({
where: { mobile, id: { not: id } },
select: { id: true },
});
if (mobileTaken) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const emailTaken = await this.prisma.platformAdmin.findFirst({
where: { email, id: { not: id } },
select: { id: true },
});
if (emailTaken) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
try {
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { fullName, mobile, email },
});
return this.adminToDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async deleteAdmin(id: string, actorId: string): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را حذف کنید');
}
const others = await this.prisma.platformAdmin.count({
where: { id: { not: id } },
});
if (others < 1) {
throw new BadRequestException('حداقل یک سوپرادمین باید بماند');
}
await this.prisma.platformAdmin.delete({ where: { id } });
return { ok: true };
}
async masterPasswordStatus(): Promise<{ enabled: boolean; updatedAt: string | null }> {
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { updatedAt: true },
});
return {
enabled: Boolean(row),
updatedAt: row?.updatedAt.toISOString() ?? null,
};
}
async setMasterPassword(
password: string | null,
): Promise<{ enabled: boolean; updatedAt: string | null }> {
const trimmed = password?.trim() ?? '';
if (!trimmed) {
await this.prisma.platformMasterPassword.deleteMany({ where: { id: 'default' } });
return { enabled: false, updatedAt: null };
}
if (trimmed.length < 8) {
throw new BadRequestException('رمز کلی حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(trimmed, 10);
const row = await this.prisma.platformMasterPassword.upsert({
where: { id: 'default' },
create: { id: 'default', passwordHash },
update: { passwordHash },
});
return { enabled: true, updatedAt: row.updatedAt.toISOString() };
}
private adminToDto(row: {
id: string;
mobile: string | null;