From c273dd6c58d61bb34c65fb253af874710aba7c64 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 2 Nov 2026 19:13:57 +0330 Subject: [PATCH] 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 --- .../migration.sql | 7 + apps/api/prisma/schema.prisma | 7 + apps/api/src/auth/auth.service.ts | 44 +++- .../conversations/conversations.service.ts | 2 +- apps/api/src/platform/dto.ts | 25 ++ apps/api/src/platform/platform.controller.ts | 24 ++ apps/api/src/platform/platform.service.ts | 110 +++++++- apps/web/src/layouts/AdmLayout.vue | 11 +- apps/web/src/router/index.ts | 10 + apps/web/src/views/adm/AdmAdminsView.vue | 170 ++++++++++++- .../src/views/adm/AdmBillingSettingsView.vue | 166 ++++++++++++ .../web/src/views/adm/AdmChatSettingsView.vue | 228 +++++++++++++++++ apps/web/src/views/adm/AdmDashboardView.vue | 237 ++++++++++++++++-- .../src/views/adm/AdmOrganizationsView.vue | 178 ------------- apps/web/src/views/adm/AdmUsersView.vue | 46 +--- packages/shared/src/index.ts | 19 ++ 16 files changed, 1031 insertions(+), 253 deletions(-) create mode 100644 apps/api/prisma/migrations/20261102193000_platform_master_password/migration.sql create mode 100644 apps/web/src/views/adm/AdmBillingSettingsView.vue create mode 100644 apps/web/src/views/adm/AdmChatSettingsView.vue diff --git a/apps/api/prisma/migrations/20261102193000_platform_master_password/migration.sql b/apps/api/prisma/migrations/20261102193000_platform_master_password/migration.sql new file mode 100644 index 0000000..a194918 --- /dev/null +++ b/apps/api/prisma/migrations/20261102193000_platform_master_password/migration.sql @@ -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") +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 328d16d..af53b34 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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()) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index c6194aa..2dafba4 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { - 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 { 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 { + 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 { 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 }, diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index b39c923..49802cf 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -656,7 +656,7 @@ export class ConversationsService { }); if (!row) { throw new BadRequestException( - 'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیط‌ها → تنظیمات گفتگو پیکربندی کنید.', + 'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.', ); } try { diff --git a/apps/api/src/platform/dto.ts b/apps/api/src/platform/dto.ts index 9353191..74e0362 100644 --- a/apps/api/src/platform/dto.ts +++ b/apps/api/src/platform/dto.ts @@ -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; diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts index 64f5baf..f8a1d72 100644 --- a/apps/api/src/platform/platform.controller.ts +++ b/apps/api/src/platform/platform.controller.ts @@ -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); diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index 4c92dd6..5b02b0e 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -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 { + 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; diff --git a/apps/web/src/layouts/AdmLayout.vue b/apps/web/src/layouts/AdmLayout.vue index 2aec556..da67328 100644 --- a/apps/web/src/layouts/AdmLayout.vue +++ b/apps/web/src/layouts/AdmLayout.vue @@ -20,6 +20,10 @@ const pageTitle = computed(() => { return 'محیط‌ها'; case 'adm-users': return 'کاربران'; + case 'adm-chat': + return 'گفتگو'; + case 'adm-billing': + return 'اشتراک'; case 'adm-admins': return 'ادمین‌ها'; case 'adm-sms': @@ -33,6 +37,8 @@ const tabs = [ { name: 'adm-dashboard', label: 'خانه', icon: 'home' as const }, { name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const }, { name: 'adm-users', label: 'کاربران', icon: 'users' as const }, + { name: 'adm-chat', label: 'گفتگو', icon: 'chat' as const }, + { name: 'adm-billing', label: 'اشتراک', icon: 'crown' as const }, { name: 'adm-sms', label: 'پیامک', icon: 'mail' as const }, { name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const }, ]; @@ -372,7 +378,10 @@ function onLogout() { position: sticky; bottom: 0; z-index: 30; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: none; + grid-auto-flow: column; + grid-auto-columns: minmax(4.4rem, 1fr); + overflow-x: auto; gap: 0.15rem; padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom)); background: rgba(255, 255, 255, 0.92); diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index cb05ae8..4145310 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -57,6 +57,16 @@ export const router = createRouter({ name: 'adm-users', component: () => import('@/views/adm/AdmUsersView.vue'), }, + { + path: 'chat', + name: 'adm-chat', + component: () => import('@/views/adm/AdmChatSettingsView.vue'), + }, + { + path: 'billing', + name: 'adm-billing', + component: () => import('@/views/adm/AdmBillingSettingsView.vue'), + }, { path: 'admins', name: 'adm-admins', diff --git a/apps/web/src/views/adm/AdmAdminsView.vue b/apps/web/src/views/adm/AdmAdminsView.vue index da14cc7..599afa3 100644 --- a/apps/web/src/views/adm/AdmAdminsView.vue +++ b/apps/web/src/views/adm/AdmAdminsView.vue @@ -1,7 +1,12 @@ + + + + diff --git a/apps/web/src/views/adm/AdmChatSettingsView.vue b/apps/web/src/views/adm/AdmChatSettingsView.vue new file mode 100644 index 0000000..df7cef9 --- /dev/null +++ b/apps/web/src/views/adm/AdmChatSettingsView.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/apps/web/src/views/adm/AdmDashboardView.vue b/apps/web/src/views/adm/AdmDashboardView.vue index c6fcef6..e04f2f1 100644 --- a/apps/web/src/views/adm/AdmDashboardView.vue +++ b/apps/web/src/views/adm/AdmDashboardView.vue @@ -1,17 +1,40 @@